rigortype 0.3.3 → 0.3.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 (171) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/array.yml +11 -24
  4. data/data/builtins/ruby_core/enumerable.yml +4 -8
  5. data/data/builtins/ruby_core/exception.yml +6 -9
  6. data/data/builtins/ruby_core/hash.yml +13 -20
  7. data/data/builtins/ruby_core/io.yml +6 -9
  8. data/data/builtins/ruby_core/numeric.yml +2 -3
  9. data/data/builtins/ruby_core/proc.yml +4 -8
  10. data/data/builtins/ruby_core/re.yml +6 -9
  11. data/data/builtins/ruby_core/set.yml +2 -3
  12. data/data/builtins/ruby_core/string.yml +4 -7
  13. data/data/builtins/ruby_core/struct.yml +0 -6
  14. data/data/builtins/ruby_core/time.yml +3 -6
  15. data/data/effects/core.yml +860 -0
  16. data/data/effects/registry.yml +95 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +359 -2
  19. data/docs/manual/03-configuration.md +101 -1
  20. data/docs/manual/04-diagnostics.md +8 -3
  21. data/docs/manual/11-ci.md +37 -0
  22. data/docs/manual/12-caching.md +39 -0
  23. data/docs/manual/16-rbs-extended-annotations.md +90 -0
  24. data/docs/manual/18-removing-dead-code.md +326 -0
  25. data/docs/manual/19-effect-labels.md +671 -0
  26. data/docs/manual/README.md +10 -0
  27. data/docs/manual/ci-templates/README.md +9 -0
  28. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  29. data/docs/manual/plugins/rigor-activejob.md +47 -0
  30. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  31. data/docs/manual/plugins/rigor-pundit.md +26 -0
  32. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  33. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  34. data/docs/manual/plugins/rigor-rspec.md +8 -0
  35. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  36. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  37. data/lib/rigor/analysis/incremental_session.rb +140 -6
  38. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  39. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  40. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  41. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  42. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  43. data/lib/rigor/analysis/rule_catalog.rb +168 -3
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/run_cache_probe.rb +69 -1
  46. data/lib/rigor/analysis/runner/declaration_position.rb +38 -0
  47. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  48. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  49. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  50. data/lib/rigor/analysis/runner/pool_coordinator.rb +93 -10
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
  52. data/lib/rigor/analysis/runner.rb +429 -10
  53. data/lib/rigor/analysis/worker_session.rb +60 -2
  54. data/lib/rigor/bleeding_edge.rb +22 -0
  55. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  56. data/lib/rigor/cli/check_command.rb +13 -1
  57. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  58. data/lib/rigor/cli/effects_command.rb +245 -0
  59. data/lib/rigor/cli/effects_diff_renderer.rb +127 -0
  60. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  61. data/lib/rigor/cli/effects_renderer.rb +92 -0
  62. data/lib/rigor/cli/effects_report.rb +163 -0
  63. data/lib/rigor/cli/effects_snapshot_command.rb +275 -0
  64. data/lib/rigor/cli/unused_command.rb +288 -0
  65. data/lib/rigor/cli.rb +28 -1
  66. data/lib/rigor/configuration/severity_profile.rb +23 -0
  67. data/lib/rigor/configuration.rb +322 -28
  68. data/lib/rigor/configuration_error.rb +20 -0
  69. data/lib/rigor/effects/attribution.rb +76 -0
  70. data/lib/rigor/effects/catalog.rb +275 -0
  71. data/lib/rigor/effects/collector.rb +219 -0
  72. data/lib/rigor/effects/config_envelopes.rb +185 -0
  73. data/lib/rigor/effects/discharge.rb +69 -0
  74. data/lib/rigor/effects/effect_table.rb +92 -0
  75. data/lib/rigor/effects/entry_points.rb +139 -0
  76. data/lib/rigor/effects/envelope.rb +86 -0
  77. data/lib/rigor/effects/envelope_check.rb +172 -0
  78. data/lib/rigor/effects/envelope_index.rb +157 -0
  79. data/lib/rigor/effects/file_collection.rb +174 -0
  80. data/lib/rigor/effects/framework_units.rb +222 -0
  81. data/lib/rigor/effects/identity.rb +104 -0
  82. data/lib/rigor/effects/inline_anchor.rb +134 -0
  83. data/lib/rigor/effects/label.rb +77 -0
  84. data/lib/rigor/effects/label_intent.rb +73 -0
  85. data/lib/rigor/effects/label_set.rb +136 -0
  86. data/lib/rigor/effects/liskov_check.rb +167 -0
  87. data/lib/rigor/effects/local_ownership.rb +132 -0
  88. data/lib/rigor/effects/method_key.rb +40 -0
  89. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  90. data/lib/rigor/effects/narrowing.rb +202 -0
  91. data/lib/rigor/effects/origin.rb +66 -0
  92. data/lib/rigor/effects/path_finder.rb +89 -0
  93. data/lib/rigor/effects/plugin_facts.rb +384 -0
  94. data/lib/rigor/effects/propagator.rb +335 -0
  95. data/lib/rigor/effects/registry.rb +200 -0
  96. data/lib/rigor/effects/scanner.rb +302 -0
  97. data/lib/rigor/effects/signature_sources.rb +90 -0
  98. data/lib/rigor/effects/snapshot.rb +396 -0
  99. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  100. data/lib/rigor/effects/summary.rb +154 -0
  101. data/lib/rigor/effects/taint_cause.rb +39 -0
  102. data/lib/rigor/effects/unit_scan.rb +647 -0
  103. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  104. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  105. data/lib/rigor/effects.rb +47 -0
  106. data/lib/rigor/environment/rbs_loader.rb +34 -0
  107. data/lib/rigor/environment.rb +8 -1
  108. data/lib/rigor/flow_contribution/element.rb +1 -0
  109. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  110. data/lib/rigor/flow_contribution/merger.rb +16 -1
  111. data/lib/rigor/flow_contribution.rb +20 -4
  112. data/lib/rigor/inference/expression_typer.rb +18 -4
  113. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  114. data/lib/rigor/inference/scope_indexer.rb +15 -3
  115. data/lib/rigor/plugin/base.rb +28 -0
  116. data/lib/rigor/plugin/box.rb +18 -2
  117. data/lib/rigor/plugin/effect_ancestry.rb +80 -0
  118. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  119. data/lib/rigor/plugin/effect_edge.rb +101 -0
  120. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  121. data/lib/rigor/plugin/first_party.rb +57 -0
  122. data/lib/rigor/plugin/isolation.rb +4 -1
  123. data/lib/rigor/plugin/manifest.rb +127 -3
  124. data/lib/rigor/plugin/registry.rb +55 -0
  125. data/lib/rigor/plugin.rb +1 -0
  126. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  127. data/lib/rigor/rbs_extended.rb +188 -0
  128. data/lib/rigor/reflection.rb +131 -30
  129. data/lib/rigor/version.rb +1 -1
  130. data/lib/rigor.rb +1 -0
  131. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  133. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  134. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  135. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  136. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  137. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  138. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  139. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  140. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  141. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  142. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  143. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  144. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  145. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  146. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  147. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +401 -29
  148. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
  149. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  150. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  151. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  152. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  153. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  154. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  155. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  156. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  157. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  158. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  159. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  160. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  161. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  162. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
  164. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  165. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +68 -3
  166. data/sig/rigor/analysis/fact_store.rbs +1 -0
  167. data/sig/rigor/inference.rbs +2 -0
  168. data/sig/rigor/rbs_extended.rbs +6 -0
  169. data/sig/rigor.rbs +17 -1
  170. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  171. metadata +84 -1
@@ -0,0 +1,647 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../source/constant_path"
6
+ require_relative "../source/node_children"
7
+ require_relative "attribution"
8
+ require_relative "catalog"
9
+ require_relative "envelope_index"
10
+ require_relative "file_collection"
11
+ require_relative "label_set"
12
+ require_relative "mutation_classifier"
13
+ require_relative "narrowing"
14
+ require_relative "origin"
15
+ require_relative "plugin_facts"
16
+ require_relative "summary"
17
+
18
+ module Rigor
19
+ module Effects
20
+ # Scans one **effect unit** — a method body — into its direct {Summary} plus the unresolved edges the
21
+ # propagator later closes over (ADR-103 WD4).
22
+ #
23
+ # Two rules shape the walk:
24
+ #
25
+ # - **Containment.** A block literal's origins always join the enclosing method's summary, whether the
26
+ # callee invokes the block now, later, or never; an envelope is a contract about the method's *code*.
27
+ # So the walk simply descends into `BlockNode`s and stops only at a nested `def` or at a
28
+ # `define_method` with a literal name, both of which are units of their own.
29
+ # - **Observation.** Everything the walk knows about a receiver comes from what the typer already
30
+ # decided at that call node ({Collector::CallRecord}); the scan resolves nothing, walks no callee,
31
+ # and touches no `Scope`.
32
+ #
33
+ # What it cannot prove it taints, and a taint is never a finding — the summary reads "these effects,
34
+ # and possibly more".
35
+ # Long by construction: the walk carries one `when` per Ruby construct that originates an effect, and
36
+ # splitting that table across classes would put the vocabulary in one file and the reasons in another.
37
+ class UnitScan # rubocop:disable Metrics/ClassLength
38
+ # `$~` and friends are frame-local, not global state: a read of one is not `global.read`. (Prism
39
+ # gives `$1` and `$&` node types of their own, so only the named specials need listing.)
40
+ FRAME_LOCAL_GLOBALS = %w[$~ $_ $& $` $' $+ $!].to_set.freeze
41
+
42
+ REFLECTIVE_SEND = %i[send public_send __send__].to_set.freeze
43
+
44
+ # Selectors a per-class POSTURE default must never answer for, because a more specific reading of
45
+ # the same site exists and would be swallowed: `send` and friends are the `dynamic-send` taint, and
46
+ # `call` is the `opaque-callable` one. An explicit ROW still wins (`Fiddle::Function#call` is
47
+ # `ffi`) — it is only the class default that steps aside.
48
+ DEFERRED_SELECTORS = %i[send public_send __send__ call].to_set.freeze
49
+
50
+ # `eval` and its family with a *string* argument, and `binding`, hand the analyzer code it cannot
51
+ # read. The design note § 5.1 puts them outside the catalogue for exactly that reason: there is no
52
+ # upper bound to give, only the honest "and possibly more". The BLOCK forms
53
+ # (`instance_eval { … }`) are containment and must not taint, so the taint is conditioned on a
54
+ # positional argument being present.
55
+ EVAL_SELECTORS = %i[eval instance_eval class_eval module_eval].to_set.freeze
56
+
57
+ # The construct origins this scan can produce, spelled once. A construct origin is line-free and
58
+ # carries no per-site state, so every site that produces one produces the SAME value — allocating a
59
+ # fresh `Data` per `@ivar` write in a project's every method was pure garbage.
60
+ DEFINE_METHOD = Origin.construct("define-method")
61
+ XSTRING = Origin.construct("xstring")
62
+ GVAR_READ = Origin.construct("gvar-read")
63
+ GVAR_WRITE = Origin.construct("gvar-write")
64
+ CVAR_READ = Origin.construct("cvar-read")
65
+ CVAR_WRITE = Origin.construct("cvar-write")
66
+ IVAR_WRITE = Origin.construct("ivar-write")
67
+ ALIAS = Origin.construct("alias")
68
+ UNDEF = Origin.construct("undef")
69
+ RECEIVER_MUTATION = Origin.construct("receiver-mutation")
70
+ private_constant :DEFINE_METHOD, :XSTRING, :GVAR_READ, :GVAR_WRITE, :CVAR_READ, :CVAR_WRITE,
71
+ :IVAR_WRITE, :ALIAS, :UNDEF, :RECEIVER_MUTATION
72
+
73
+ # What a receiver rooted at implicit self spells as the head of a receiver path. Must agree with
74
+ # `Rigor::Plugin::EffectAttribution::SELF_HEAD`, which is what a plugin writes; spelled again rather
75
+ # than required so the effects namespace does not pull the plugin contract in, and pinned by spec.
76
+ SELF_PATH_HEAD = "self"
77
+
78
+ GLOBAL_READ = LabelSet.new(["global.read"])
79
+ GLOBAL_WRITE = LabelSet.new(["global.write"])
80
+ MUTATE_STATIC = LabelSet.new(["mutate.static"])
81
+ MUTATE_SELF = LabelSet.new(["mutate.self"])
82
+ IO_PROCESS = LabelSet.new(["io.process"])
83
+
84
+ # `define_method(:literal) { … }` — the block becomes `literal`'s body (WD14), so the call is a unit
85
+ # declaration wherever it appears: in a class body it is the only way that method exists, and inside
86
+ # another method it is a definition the enclosing method performs (`mutate.static`) rather than code
87
+ # the enclosing method contains. A non-literal name has no key to file the block under, so it stays
88
+ # contained in the enclosing method and this returns nil.
89
+ #
90
+ # @return [Array, nil] `[name, singleton, body, parameters]`
91
+ def self.define_method_unit(node)
92
+ return nil unless node.name == :define_method && node.receiver.nil?
93
+
94
+ first = node.arguments&.arguments&.first
95
+ return nil unless first.is_a?(Prism::SymbolNode) && first.unescaped
96
+
97
+ block = node.block
98
+ return nil unless block.is_a?(Prism::BlockNode)
99
+
100
+ [first.unescaped, false, block.body, block.parameters]
101
+ end
102
+
103
+ # @param singleton [Boolean] whether the unit's `self` is the class object (`def self.x`,
104
+ # `class << self`) — the axis that separates `mutate.self` from `mutate.static` on an ivar write
105
+ # @param block_parameter [String, nil] the unit's `&blk` parameter name, if any; a call on it is
106
+ # forwarding, not an opaque callable
107
+ # @param calls [Hash] node-identity table of {Collector::CallRecord}s
108
+ # @param attribution [Attribution] the project's `effects.attribution:` table
109
+ # @param envelopes [EnvelopeIndex] the envelopes a call site may import as a `≤` bound (#386)
110
+ # @param plugin_facts [PluginFacts] the loaded plugins' `effect_attributions:` (#387)
111
+ # @param owner_class [String, nil] the class this unit is defined on — the carrier an
112
+ # implicit-self call's envelope is looked up under, since the syntax spells `Kernel#name`
113
+ # @param method_name [String, nil] this unit's own selector — what a `super` in its body names as
114
+ # the target the propagator resolves above `owner_class` (#446). With no name to state, a `super`
115
+ # taints instead.
116
+ def initialize(singleton:, parameters:, block_parameter:, owned_locals:, calls:, # rubocop:disable Metrics/ParameterLists
117
+ attribution: Attribution.empty, envelopes: EnvelopeIndex.empty,
118
+ plugin_facts: PluginFacts.empty, owner_class: nil, method_name: nil)
119
+ @singleton = singleton
120
+ @block_parameter = block_parameter
121
+ @calls = calls
122
+ @attribution = attribution
123
+ @envelopes = envelopes
124
+ @plugin_facts = plugin_facts
125
+ @owner_class = owner_class
126
+ @method_name = method_name
127
+ @mutation = MutationClassifier.new(
128
+ singleton: singleton, parameters: parameters, owned_locals: owned_locals
129
+ )
130
+ @bundles = {}
131
+ @declared_bundles = {}
132
+ @causes = []
133
+ @edges = []
134
+ @nested = []
135
+ @delegates_upward = false
136
+ end
137
+
138
+ # Units discovered inside this one — a nested `def`, or a `define_method` with a literal name whose
139
+ # block becomes that method's body. Each is `[name, singleton, body_node, parameters_node]`.
140
+ attr_reader :nested
141
+
142
+ # Whether this body reaches `super` — an override that delegates upward still runs whatever the
143
+ # superclass does. Nested `def`s are unit boundaries, so a `super` counted here is this unit's.
144
+ #
145
+ # A separate reading of the same node from the edge {#visit_super} records: what this bit answers is
146
+ # whether a framework's claim about the selector survives the class having written a body for it
147
+ # ({FrameworkUnits.replaced?}, #440), which is a question about the *class*, not about what the
148
+ # parent implementation does.
149
+ def delegates_upward?
150
+ @delegates_upward
151
+ end
152
+
153
+ # Walks `body` and returns `[Summary, edges]`.
154
+ def run(body)
155
+ walk(body)
156
+ summary = Summary.new(
157
+ bundles: @bundles, declared_bundles: @declared_bundles,
158
+ exhaustive: @causes.empty?, causes: @causes
159
+ )
160
+ [summary, @edges]
161
+ end
162
+
163
+ private
164
+
165
+ def add(origin, labels)
166
+ return if labels.empty?
167
+
168
+ @bundles[origin] = @bundles.key?(origin) ? @bundles[origin].join(labels) : labels
169
+ end
170
+
171
+ def add_declared(origin, labels)
172
+ return if labels.empty?
173
+
174
+ @declared_bundles[origin] = @declared_bundles.key?(origin) ? @declared_bundles[origin].join(labels) : labels
175
+ end
176
+
177
+ def taint(cause, detail = nil)
178
+ @causes << [cause, detail]
179
+ end
180
+
181
+ def walk(node)
182
+ return unless node.is_a?(Prism::Node)
183
+ return if unit_boundary?(node)
184
+
185
+ visit(node)
186
+ node.rigor_each_child { |child| walk(child) }
187
+ end
188
+
189
+ # A nested unit is recorded and NOT descended into: its body belongs to its own summary, and the
190
+ # enclosing method gets only the `mutate.static` of having defined it.
191
+ def unit_boundary?(node)
192
+ case node
193
+ when Prism::DefNode
194
+ @nested << [node.name.to_s, !node.receiver.nil?, node.body, node.parameters]
195
+ true
196
+ when Prism::CallNode
197
+ declared = self.class.define_method_unit(node)
198
+ return false unless declared
199
+
200
+ @nested << declared
201
+ add(DEFINE_METHOD, MUTATE_STATIC)
202
+ true
203
+ else
204
+ false
205
+ end
206
+ end
207
+
208
+ def visit(node) # rubocop:disable Metrics/CyclomaticComplexity
209
+ case node
210
+ when Prism::CallNode then visit_call(node)
211
+ when Prism::XStringNode, Prism::InterpolatedXStringNode
212
+ add(XSTRING, IO_PROCESS)
213
+ when Prism::GlobalVariableReadNode
214
+ add(GVAR_READ, GLOBAL_READ) unless FRAME_LOCAL_GLOBALS.include?(node.name.to_s)
215
+ when Prism::GlobalVariableWriteNode, Prism::GlobalVariableOperatorWriteNode,
216
+ Prism::GlobalVariableOrWriteNode, Prism::GlobalVariableAndWriteNode
217
+ add(GVAR_WRITE, GLOBAL_WRITE)
218
+ when Prism::ClassVariableReadNode
219
+ add(CVAR_READ, GLOBAL_READ)
220
+ when Prism::ClassVariableWriteNode, Prism::ClassVariableOperatorWriteNode,
221
+ Prism::ClassVariableOrWriteNode, Prism::ClassVariableAndWriteNode
222
+ add(CVAR_WRITE, MUTATE_STATIC)
223
+ when Prism::InstanceVariableWriteNode, Prism::InstanceVariableOperatorWriteNode,
224
+ Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode
225
+ add(IVAR_WRITE, @singleton ? MUTATE_STATIC : MUTATE_SELF)
226
+ when Prism::AliasMethodNode, Prism::AliasGlobalVariableNode
227
+ add(ALIAS, MUTATE_STATIC)
228
+ when Prism::UndefNode
229
+ add(UNDEF, MUTATE_STATIC)
230
+ when Prism::IndexOperatorWriteNode, Prism::IndexOrWriteNode, Prism::IndexAndWriteNode,
231
+ Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode
232
+ classify_mutation(node.receiver)
233
+ when Prism::SuperNode, Prism::ForwardingSuperNode
234
+ visit_super
235
+ end
236
+ end
237
+
238
+ # `super` in every shape — bare, `super()`, `super(args)`, and each of those inside a block or a
239
+ # rescue, since the walk descends into both (#446).
240
+ #
241
+ # It is a dispatch and must contribute like one. What the scan can settle from the syntax is the
242
+ # *identity* of the target — this unit's own class and selector — and nothing more: which definition
243
+ # sits above the class is a question about the whole project, so it goes out as an edge and the
244
+ # propagator resolves it against the merged ancestry, tainting when nothing there answers.
245
+ #
246
+ # A unit with no class or no name has no target to state. That is not a shape the scanner produces,
247
+ # and the taint is what a "cannot say" must read as rather than a silently dropped call.
248
+ def visit_super
249
+ @delegates_upward = true
250
+ return taint("unresolved-super", @method_name) if @owner_class.nil? || @method_name.nil?
251
+
252
+ @edges << FileCollection::Edge.new(
253
+ receiver_class: @owner_class, kind: @singleton ? :singleton : :instance,
254
+ selector: @method_name, self_call: true, super_call: true
255
+ )
256
+ end
257
+
258
+ def visit_call(node)
259
+ record = @calls[node]
260
+ attribute(node, record)
261
+ plugin = attribute_plugin(node, record)
262
+ envelope = import_envelope(node, record)
263
+ # A DISCHARGING plugin row bounds the site exactly as an imported envelope does (ADR-103 WD6): a
264
+ # first-party bundled plugin's framework-derived attribution is a trusted claim about a callee the
265
+ # analyzer will never read, so "the receiver was Dynamic" and "no project definition answered" are
266
+ # both already accounted for. `Rails.env` on an app with no Rails RBS is the measured case — the
267
+ # row says what it does, and a `dynamic-receiver` taint beside it would be noise that never clears.
268
+ bound = envelope || (plugin&.discharge? ? plugin : nil)
269
+ visit_uncatalogued(node, record, bound) unless claimed_by_catalogue?(node, record)
270
+ visit_block_argument(node)
271
+ end
272
+
273
+ # The **plugin stratum** (#387; ADR-103 WD6 / WD10): what the plugin that models a framework says
274
+ # this call does.
275
+ #
276
+ # It runs beside the catalogue and beside the project's own `effects.attribution:`, for the reason
277
+ # both of those do: three different authorities can each have something true to say about one call,
278
+ # and a summary that reported only the nearest would be less honest, not simpler. What separates it
279
+ # from {#attribute} is the *taint*: a first-party bundled plugin's row is a discharging stratum, so
280
+ # the site stays exhaustive; a third-party plugin's is a claim and taints exactly like the project's
281
+ # own table.
282
+ #
283
+ # Three receiver shapes, tried nearest-syntax first — a written receiver path (`Rails.cache.read`),
284
+ # a receiver rooted at implicit self inside a framework class (`session[:x] = 1`), and the receiver's
285
+ # class through the project's inheritance chain (`user.save`).
286
+ # @return [PluginFacts::Row, nil] the row that claimed this call, for {#visit_call} to read as a bound
287
+ def attribute_plugin(node, record)
288
+ return nil unless @plugin_facts.attributions?
289
+
290
+ row = plugin_row(node, record)
291
+ return nil if row.nil?
292
+
293
+ labels = row.narrow ? Narrowing.apply(row.narrow, node) : row.labels
294
+ return row if labels.nil? || labels.empty?
295
+
296
+ add_declared(Origin.plugin(row.key), labels)
297
+ # A row may discharge AND still taint: `render` states exactly what the CONTROLLER does and says
298
+ # nothing about the template, which is not an effect unit yet (ADR-103 WD11).
299
+ taint(row.taint, row.key) if row.taint
300
+ taint("plugin-attribution", row.key) unless row.discharge?
301
+ row
302
+ end
303
+
304
+ def plugin_row(node, record)
305
+ receiver = node.receiver
306
+ selector = node.name.to_s
307
+ path = receiver_path(receiver)
308
+ # Nearest-syntax first, and every shape is TRIED rather than selected: `Rails.cache.read` is a
309
+ # receiver path and nothing else, but `ActiveRecord::Base.connection.execute` is both a receiver
310
+ # path (which no plugin rows) and a call on the result of `ActiveRecord::Base.connection` (which
311
+ # rigor-activerecord does row). Returning on the first shape that *applied* rather than the first
312
+ # that *matched* silently lost the second.
313
+ (path && @plugin_facts.path_row(path, selector)) ||
314
+ (path && @plugin_facts.self_path_row(path, selector, @owner_class)) ||
315
+ class_row_for(node, record, selector) ||
316
+ @plugin_facts.result_row(producer_class(receiver), selector)
317
+ end
318
+
319
+ def class_row_for(node, record, selector)
320
+ owner, singleton = envelope_target(node, record)
321
+ @plugin_facts.class_row(owner, singleton, selector)
322
+ end
323
+
324
+ # The class whose call produced this receiver, for an `on_result:` row: the constant in
325
+ # `UserMailer.welcome(u).deliver_now`, the receiver class the typer had otherwise. Nil unless the
326
+ # receiver is itself a call — a bare local variable says nothing about what made it.
327
+ #
328
+ # Two shapes beyond the constant, because between them they are how mail is actually sent (#456):
329
+ #
330
+ # - **an implicit-self producing call** — `issue_add(user, issue).deliver_later`, which is what a
331
+ # mailer's own `def self.deliver_issue_add` wrapper looks like in Redmine, 97 call sites of it.
332
+ # The producer is the unit's own class, which is exactly what an unqualified name resolves
333
+ # against;
334
+ # - **a builder one link further out** — `AdminMailer.with(recipient: a).new_trends(…).deliver_later!`,
335
+ # ActionMailer's parameterized form and the spelling Mastodon uses. `with` returns a lazy
336
+ # `Parameterized::Mailer` no project declares a type for, so the constant is two calls away rather
337
+ # than one, and stopping at the first link found nothing.
338
+ #
339
+ # The walk is the same heuristic the `on_result:` row already is — "the class that produced this" —
340
+ # applied transitively rather than once. A row still has to match the producer's ancestry AND the
341
+ # selector, so a chain that ends somewhere unrelated contributes nothing rather than a wrong label.
342
+ def producer_class(receiver)
343
+ return nil unless receiver.is_a?(Prism::CallNode)
344
+
345
+ inner = receiver.receiver
346
+ return @owner_class if inner.nil?
347
+
348
+ Source::ConstantPath.qualified_name(inner) || producer_class(inner) ||
349
+ @calls[receiver]&.receiver_class
350
+ end
351
+
352
+ # The receiver expression as the syntax spells it — `"Rails.cache"`, `"self.flash.now"` — or nil when
353
+ # the receiver is not a chain of argument-less sends off a constant or off implicit self. Arguments
354
+ # and blocks disqualify a link: `Rails.cache(x).read` is not the `Rails.cache` a row names, and
355
+ # pretending otherwise would put a proven-looking label on a call nobody wrote.
356
+ def receiver_path(node)
357
+ return nil unless node.is_a?(Prism::CallNode)
358
+ return nil unless node.arguments.nil? && node.block.nil?
359
+
360
+ inner = node.receiver
361
+ head =
362
+ if inner.nil? || inner.is_a?(Prism::SelfNode) then SELF_PATH_HEAD
363
+ else
364
+ Source::ConstantPath.qualified_name(inner) || receiver_path(inner)
365
+ end
366
+ head && "#{head}.#{node.name}"
367
+ end
368
+
369
+ # The **declared lane at a call site** (#386; ADR-103 WD6). If the callee's own declaration carries
370
+ # an envelope, the bound joins the caller's `≤` lane under an `envelope:` origin, and the returned
371
+ # value is what tells the uncatalogued path the site is bounded rather than unknown.
372
+ #
373
+ # The carrier is nominal and *static*: the class the syntax names for a constant-path receiver, the
374
+ # class the typer projected the receiver to otherwise, and this unit's own class for an
375
+ # implicit-self call — which is the only reason `owner_class` is passed in at all, since
376
+ # {#catalog_target} spells a receiver-less call `Kernel#name`.
377
+ #
378
+ # It runs BESIDE the catalogue, like {#attribute}: a catalogued row states what Ruby's surface
379
+ # proves, an envelope states what the callee promises, and a call that is both reads as both (the
380
+ # rendering rule drops a declared label the proven lane already admits, so the pair never prints
381
+ # twice).
382
+ def import_envelope(node, record)
383
+ return nil if @envelopes.empty?
384
+
385
+ owner, singleton = envelope_target(node, record)
386
+ return nil if owner.nil?
387
+
388
+ envelope = @envelopes[owner, singleton, node.name.to_s]
389
+ return nil if envelope.nil?
390
+
391
+ add_declared(Origin.envelope("#{owner}#{singleton ? '.' : '#'}#{node.name}"), envelope.bound)
392
+ envelope
393
+ end
394
+
395
+ # `[owner, singleton]` for the envelope lookup, or `[nil, false]`. Differs from {#catalog_target}
396
+ # in exactly one place, and that place is the point of the method: a receiver-less call resolves
397
+ # against self's ancestry first, so the carrier is the enclosing unit's class rather than the
398
+ # `Kernel` the catalogue reads it as.
399
+ def envelope_target(node, record)
400
+ owner, singleton, implicit = catalog_target(node, record)
401
+ return [@owner_class, @singleton] if implicit
402
+
403
+ [owner, singleton]
404
+ end
405
+
406
+ # The project's own `effects.attribution:` table, consulted on the same `(owner, selector)` the
407
+ # catalogue is looked up under ({Attribution}).
408
+ #
409
+ # It runs BESIDE the catalogue rather than instead of it, and it never claims the call: attribution
410
+ # answers a different question in a different lane. A catalogued row says what Ruby's own surface
411
+ # proves; an attribution says what the project *claims* about a body Rigor never read, so its labels
412
+ # go to the declared lane and the site keeps a `plugin-attribution` taint — "declared this, and
413
+ # possibly more" (ADR-103 WD6). A call that is both catalogued and attributed honestly reads as both.
414
+ def attribute(node, record)
415
+ return if @attribution.empty?
416
+
417
+ owner, singleton, = catalog_target(node, record)
418
+ return if owner.nil?
419
+
420
+ key = catalogue_key(owner, singleton, node)
421
+ labels = @attribution[key]
422
+ return if labels.nil?
423
+
424
+ add_declared(Origin.attribution(key), labels)
425
+ taint("plugin-attribution", key)
426
+ end
427
+
428
+ # The catalogued path. Answers whether the catalogue claimed this call — a claim suppresses the
429
+ # uncatalogued reading, which is what an explicit ∅ row is for.
430
+ #
431
+ # A **row** is authoritative: it states everything that call does, and `mutates: receiver` is how it
432
+ # asks for the ownership judgment on top (`ENV["k"] = v` is `global.write` and deliberately not a
433
+ # receiver mutation; `Time#localtime` is both). A **posture** states nothing about this selector,
434
+ # so the uncatalogued path's own mutation rule applies to it unchanged.
435
+ def claimed_by_catalogue?(node, record)
436
+ owner, singleton, implicit, named = catalog_target(node, record)
437
+ return false if owner.nil?
438
+
439
+ entry = Catalog.default.lookup(
440
+ owner, node.name.to_s, singleton: singleton, call_node: node,
441
+ posture: posture_allowed?(node, record, implicit, named)
442
+ )
443
+ return false if entry.nil?
444
+
445
+ add(Origin.catalogue(catalogue_key(owner, singleton, node)), entry.labels) unless entry.labels.empty?
446
+ classify_mutation(node.receiver) if mutating_catalogued?(node, entry, owner)
447
+ push_edge(record, node.name.to_s, implicit) if keeps_project_edge?(entry, implicit)
448
+ true
449
+ end
450
+
451
+ # Whether a claimed call still contributes its project edge. Two shapes do, and the summary is
452
+ # then the union of the catalogue's reading and the project definition's:
453
+ #
454
+ # - an IMPLICIT-SELF call, because an unqualified name resolves against self's ancestry first and
455
+ # a project method of the same name wins at run time. `Kernel#format` is a real row and
456
+ # `CustomField#format` is a real method, and only the union reads both correctly. (Redmine's
457
+ # `format.cast_value(…)` is the measured case: the row alone silently cut the callee off.)
458
+ # - a POSTURE answer, which is a class default rather than a statement about this selector, so a
459
+ # core class the project reopens still propagates its override's summary.
460
+ #
461
+ # An edge that reaches no project definition is dropped by the propagator, so the cost of keeping
462
+ # one is nothing and the cost of dropping one is a missing callee.
463
+ def keeps_project_edge?(entry, implicit)
464
+ entry.posture? || implicit
465
+ end
466
+
467
+ def mutating_catalogued?(node, entry, owner)
468
+ entry.mutates_receiver? || (entry.posture? && @mutation.mutating?(node, owner))
469
+ end
470
+
471
+ def catalogue_key(owner, singleton, node)
472
+ "#{owner}#{singleton ? '.' : '#'}#{node.name}"
473
+ end
474
+
475
+ # Whether the class's default posture may answer here. Three shapes where it may not:
476
+ #
477
+ # - an implicit-self (or `self.`) call, which spells `Kernel#name` and would otherwise colour
478
+ # every unqualified call in a project body `io`;
479
+ # - a `Dynamic` receiver, where the class the typer projected to is a guess and a default read off
480
+ # it would be a proven label with nothing proving it (the `dynamic-receiver` taint is the right
481
+ # answer, and the uncatalogued path records it);
482
+ # - `send` / `call`, whose own taints are the more specific reading ({DEFERRED_SELECTORS}).
483
+ #
484
+ # **Unless the syntax named the class** (#463). The `Dynamic` exclusion exists because a projected
485
+ # receiver class is a guess; a written constant path is not one, and {#catalog_target} already
486
+ # trusts it for the row lookup. Refusing the posture there made the catalogue's reach a function of
487
+ # whether the bundled rbs happens to ship a signature for the constant: `Net::HTTP.get` proves
488
+ # `io.net.http` and `Net::IMAP.new` proved nothing, because rbs ships `net-http` and not
489
+ # `net-imap`. That is why the `Net::FTP` row has never once fired, and why Redmine's IMAP and POP3
490
+ # pollers proved no network at all.
491
+ #
492
+ # The site is then **claimed**, and so discharges, like every other catalogue answer. That is not a
493
+ # softening of the `dynamic-receiver` rule but its premise failing: the taint says the class the
494
+ # typer projected is a guess, and on a constant path there is no projection — the receiver of
495
+ # `Net::IMAP.new` is that constant, exactly. What the posture asserts on top is the hand-audited
496
+ # claim the catalogue is made of, the same one `Socket#connect` rests on when the typer did name
497
+ # the receiver. `implicit` and {DEFERRED_SELECTORS} keep their exclusions, which are about what the
498
+ # syntax means rather than about what the typer knew.
499
+ def posture_allowed?(node, record, implicit, named)
500
+ return false if implicit || DEFERRED_SELECTORS.include?(node.name)
501
+
502
+ named || !record&.dynamic
503
+ end
504
+
505
+ def visit_uncatalogued(node, record, bound = nil)
506
+ return visit_reflective_send(node, record) if REFLECTIVE_SEND.include?(node.name)
507
+ return taint("opaque-callable") if opaque_eval?(node)
508
+
509
+ # A write is a write whatever the receiver's type turns out to be, so ownership is classified
510
+ # first and independently of the taints below: `params[:x] = 1` on an untyped `params` is a proven
511
+ # `mutate.instance` *and* a `dynamic-receiver` taint — "this much, and possibly more".
512
+ #
513
+ # Unless a discharging plugin row already stated it. `session[:user_id] = id` is an index write on
514
+ # an object no Rails app declares a type for, so the ownership judgment can only answer "unknown"
515
+ # and taint; the row answers `mutate` + `rails.session.write`, which is both more precise and
516
+ # already trusted.
517
+ classify_mutation(node.receiver) if !plugin_bound?(bound) && @mutation.mutating?(node, record&.receiver_class)
518
+
519
+ # `opaque-callable` is checked before `dynamic-receiver` because it is the more specific reading of
520
+ # the same site: a `.call` the analyzer cannot follow to a body says *what* was unfollowable, where
521
+ # a bare Dynamic receiver says only that the receiver's class was unknown.
522
+ return taint("opaque-callable") if opaque_callable?(node, record)
523
+ # **Exhaustive by envelope** (#386). A `Dynamic` receiver whose static facet still names a class
524
+ # whose method carries an envelope is not "callee unknown": whatever object arrives, the bound its
525
+ # declaration states is the upper bound of what this call can do, and that declaration is a
526
+ # discharging stratum (ADR-103 WD6 — the project's own, contract- and Liskov-checked; or an
527
+ # accepted signature, whose types are already trusted). So the site keeps its edges into the
528
+ # project definitions the closed world knows and contributes no taint.
529
+ return record_edge(node, record, bound) if record&.dynamic && bound
530
+ return taint("dynamic-receiver", record.cause) if record&.dynamic
531
+
532
+ record_edge(node, record, bound)
533
+ end
534
+
535
+ # Whether `bound` is a plugin row rather than an imported envelope. Only the mutation judgment cares
536
+ # about the difference: an envelope bounds what a CALLEE does and says nothing about whether this
537
+ # call mutates its receiver, while a plugin row is written about the call itself.
538
+ def plugin_bound?(bound)
539
+ bound.is_a?(PluginFacts::Row)
540
+ end
541
+
542
+ # `send` / `public_send` / `__send__`: a literal selector is an ordinary edge, a computed one is the
543
+ # `dynamic-send` taint.
544
+ def visit_reflective_send(node, record)
545
+ selector = literal_selector(node.arguments&.arguments&.first)
546
+ return taint("dynamic-send") unless selector
547
+
548
+ push_edge(record, selector, node.receiver.nil?)
549
+ end
550
+
551
+ def literal_selector(node)
552
+ node.unescaped if node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
553
+ end
554
+
555
+ # A `.call` the analyzer cannot follow to a body. A call on the unit's own block parameter is
556
+ # forwarding (∅ — the block's effects are accounted at the caller's literal), and a call on a
557
+ # project object resolves as an ordinary edge.
558
+ def opaque_callable?(node, record)
559
+ receiver = node.receiver
560
+ return false unless node.name == :call && receiver
561
+ return false if receiver.is_a?(Prism::LambdaNode)
562
+ return false if receiver.is_a?(Prism::LocalVariableReadNode) && receiver.name.to_s == @block_parameter
563
+
564
+ record.nil? || record.receiver_class.nil? || %w[Proc Method].include?(record.receiver_class)
565
+ end
566
+
567
+ # The edge is recorded speculatively — only the propagator can say whether `(receiver class,
568
+ # selector)` reaches a project definition — and the taint is decided independently, from the typer's
569
+ # own verdict. The two are not exclusive: a call the typer could not resolve still names a receiver
570
+ # class, and an edge that resolves to nothing is silently dropped rather than tainting, because most
571
+ # such calls are ordinary inherited ones the catalogue simply has no row for.
572
+ def record_edge(node, record, bound = nil)
573
+ self_call = node.receiver.nil?
574
+ push_edge(record, node.name.to_s, self_call)
575
+ return unless self_call && (record.nil? || !record.resolved)
576
+ # An envelope on this unit's own class for the very selector the dispatcher declined is the
577
+ # project declaring the method and stating its bound; a discharging plugin row on the framework
578
+ # base class is the plugin doing the same for a method the framework supplies. `render` inside a
579
+ # controller is the case — the dispatcher rightly declines, because the definition is in Action
580
+ # Pack. There is nothing left to be unsure about that the bound does not already answer.
581
+ return if bound
582
+
583
+ taint("unresolved-self-call", node.name.to_s)
584
+ end
585
+
586
+ def push_edge(record, selector, self_call)
587
+ return if record.nil? || record.receiver_class.nil?
588
+
589
+ @edges << FileCollection::Edge.new(
590
+ receiver_class: record.receiver_class, kind: record.kind, selector: selector, self_call: self_call
591
+ )
592
+ end
593
+
594
+ def visit_block_argument(node)
595
+ block = node.block
596
+ return unless block.is_a?(Prism::BlockArgumentNode)
597
+
598
+ expression = block.expression
599
+ return if expression.nil? || expression.is_a?(Prism::SymbolNode)
600
+ return if expression.is_a?(Prism::LocalVariableReadNode) && expression.name.to_s == @block_parameter
601
+
602
+ taint("opaque-callable")
603
+ end
604
+
605
+ def classify_mutation(receiver)
606
+ labels = @mutation.label_for(receiver)
607
+ return taint("unknown-ownership") if labels.nil?
608
+
609
+ add(RECEIVER_MUTATION, labels)
610
+ end
611
+
612
+ # An `eval` family call carrying code rather than a block, or a bare `binding`. Both hand the
613
+ # analyzer source it will not read; the design note § 5.1 makes them a taint rather than a
614
+ # catalogue row, because there is no upper bound to state.
615
+ def opaque_eval?(node)
616
+ return node.receiver.nil? && node.arguments.nil? if node.name == :binding
617
+ return false unless EVAL_SELECTORS.include?(node.name)
618
+
619
+ positional_arity(node).positive?
620
+ end
621
+
622
+ # The class the catalogue would look this call up under, as `[owner, singleton, implicit_self]`.
623
+ # Spelled from the syntax where the syntax settles it and from the typer's receiver otherwise;
624
+ # `[nil, …]` when neither does.
625
+ # `[owner, singleton, implicit, named]`. `named` says the owner came from a **constant path the
626
+ # author wrote**, which is what licenses the posture on a receiver the typer could not resolve
627
+ # ({#posture_allowed?}).
628
+ def catalog_target(node, record)
629
+ receiver = node.receiver
630
+ return ["Kernel", false, true, false] if receiver.nil? || receiver.is_a?(Prism::SelfNode)
631
+
632
+ constant = Source::ConstantPath.qualified_name(receiver)
633
+ return [constant, !Catalog.default.object_constant?(constant), false, true] if constant
634
+ return NO_TARGET if record.nil? || record.receiver_class.nil?
635
+
636
+ [record.receiver_class, record.kind == :singleton, false, false]
637
+ end
638
+
639
+ NO_TARGET = [nil, false, false, false].freeze
640
+ private_constant :NO_TARGET
641
+
642
+ def positional_arity(node)
643
+ node.arguments&.arguments&.count { |argument| !argument.is_a?(Prism::KeywordHashNode) } || 0
644
+ end
645
+ end
646
+ end
647
+ end