rigortype 0.3.3 → 0.3.4

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 (158) 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 +843 -0
  16. data/data/effects/registry.yml +67 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +311 -1
  19. data/docs/manual/03-configuration.md +84 -1
  20. data/docs/manual/04-diagnostics.md +6 -2
  21. data/docs/manual/16-rbs-extended-annotations.md +77 -0
  22. data/docs/manual/18-removing-dead-code.md +326 -0
  23. data/docs/manual/README.md +5 -0
  24. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  25. data/docs/manual/plugins/rigor-activejob.md +47 -0
  26. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  27. data/docs/manual/plugins/rigor-pundit.md +26 -0
  28. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  29. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  30. data/docs/manual/plugins/rigor-rspec.md +8 -0
  31. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  32. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  33. data/lib/rigor/analysis/incremental_session.rb +140 -6
  34. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  35. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  36. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  37. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  38. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  39. data/lib/rigor/analysis/rule_catalog.rb +158 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  41. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  42. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  43. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  44. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  45. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  46. data/lib/rigor/analysis/runner.rb +420 -10
  47. data/lib/rigor/analysis/worker_session.rb +58 -2
  48. data/lib/rigor/bleeding_edge.rb +22 -0
  49. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  50. data/lib/rigor/cli/check_command.rb +13 -1
  51. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  52. data/lib/rigor/cli/effects_command.rb +131 -0
  53. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  54. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  55. data/lib/rigor/cli/effects_renderer.rb +54 -0
  56. data/lib/rigor/cli/effects_report.rb +53 -0
  57. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  58. data/lib/rigor/cli/unused_command.rb +288 -0
  59. data/lib/rigor/cli.rb +16 -0
  60. data/lib/rigor/configuration/severity_profile.rb +23 -0
  61. data/lib/rigor/configuration.rb +296 -14
  62. data/lib/rigor/effects/attribution.rb +76 -0
  63. data/lib/rigor/effects/catalog.rb +275 -0
  64. data/lib/rigor/effects/collector.rb +182 -0
  65. data/lib/rigor/effects/config_envelopes.rb +185 -0
  66. data/lib/rigor/effects/discharge.rb +69 -0
  67. data/lib/rigor/effects/effect_table.rb +92 -0
  68. data/lib/rigor/effects/entry_points.rb +93 -0
  69. data/lib/rigor/effects/envelope.rb +86 -0
  70. data/lib/rigor/effects/envelope_check.rb +172 -0
  71. data/lib/rigor/effects/envelope_index.rb +157 -0
  72. data/lib/rigor/effects/file_collection.rb +160 -0
  73. data/lib/rigor/effects/framework_units.rb +167 -0
  74. data/lib/rigor/effects/identity.rb +104 -0
  75. data/lib/rigor/effects/label.rb +77 -0
  76. data/lib/rigor/effects/label_intent.rb +73 -0
  77. data/lib/rigor/effects/label_set.rb +136 -0
  78. data/lib/rigor/effects/liskov_check.rb +167 -0
  79. data/lib/rigor/effects/local_ownership.rb +132 -0
  80. data/lib/rigor/effects/method_key.rb +40 -0
  81. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  82. data/lib/rigor/effects/narrowing.rb +202 -0
  83. data/lib/rigor/effects/origin.rb +66 -0
  84. data/lib/rigor/effects/path_finder.rb +89 -0
  85. data/lib/rigor/effects/plugin_facts.rb +335 -0
  86. data/lib/rigor/effects/propagator.rb +275 -0
  87. data/lib/rigor/effects/registry.rb +193 -0
  88. data/lib/rigor/effects/scanner.rb +290 -0
  89. data/lib/rigor/effects/signature_sources.rb +74 -0
  90. data/lib/rigor/effects/snapshot.rb +380 -0
  91. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  92. data/lib/rigor/effects/summary.rb +154 -0
  93. data/lib/rigor/effects/taint_cause.rb +38 -0
  94. data/lib/rigor/effects/unit_scan.rb +572 -0
  95. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  96. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  97. data/lib/rigor/effects.rb +47 -0
  98. data/lib/rigor/environment/rbs_loader.rb +34 -0
  99. data/lib/rigor/environment.rb +8 -1
  100. data/lib/rigor/flow_contribution/element.rb +1 -0
  101. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  102. data/lib/rigor/flow_contribution/merger.rb +16 -1
  103. data/lib/rigor/flow_contribution.rb +20 -4
  104. data/lib/rigor/inference/expression_typer.rb +18 -4
  105. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  106. data/lib/rigor/inference/scope_indexer.rb +15 -3
  107. data/lib/rigor/plugin/base.rb +24 -0
  108. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  109. data/lib/rigor/plugin/effect_edge.rb +101 -0
  110. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  111. data/lib/rigor/plugin/first_party.rb +57 -0
  112. data/lib/rigor/plugin/isolation.rb +4 -1
  113. data/lib/rigor/plugin/manifest.rb +103 -3
  114. data/lib/rigor/plugin/registry.rb +48 -0
  115. data/lib/rigor/plugin.rb +1 -0
  116. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  117. data/lib/rigor/rbs_extended.rb +168 -0
  118. data/lib/rigor/reflection.rb +131 -30
  119. data/lib/rigor/version.rb +1 -1
  120. data/lib/rigor.rb +1 -0
  121. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  122. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  123. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  124. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  125. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  126. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  127. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  128. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  129. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  130. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  131. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  132. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  133. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  134. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  135. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  136. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  137. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  138. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  139. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  140. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  141. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  142. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  143. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  144. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  145. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  146. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  147. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  148. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  149. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  150. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  151. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  152. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  153. data/sig/rigor/analysis/fact_store.rbs +1 -0
  154. data/sig/rigor/inference.rbs +2 -0
  155. data/sig/rigor/rbs_extended.rbs +6 -0
  156. data/sig/rigor.rbs +17 -1
  157. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  158. metadata +79 -1
@@ -0,0 +1,572 @@
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
+ def initialize(singleton:, parameters:, block_parameter:, owned_locals:, calls:, # rubocop:disable Metrics/ParameterLists
114
+ attribution: Attribution.empty, envelopes: EnvelopeIndex.empty,
115
+ plugin_facts: PluginFacts.empty, owner_class: nil)
116
+ @singleton = singleton
117
+ @block_parameter = block_parameter
118
+ @calls = calls
119
+ @attribution = attribution
120
+ @envelopes = envelopes
121
+ @plugin_facts = plugin_facts
122
+ @owner_class = owner_class
123
+ @mutation = MutationClassifier.new(
124
+ singleton: singleton, parameters: parameters, owned_locals: owned_locals
125
+ )
126
+ @bundles = {}
127
+ @declared_bundles = {}
128
+ @causes = []
129
+ @edges = []
130
+ @nested = []
131
+ end
132
+
133
+ # Units discovered inside this one — a nested `def`, or a `define_method` with a literal name whose
134
+ # block becomes that method's body. Each is `[name, singleton, body_node, parameters_node]`.
135
+ attr_reader :nested
136
+
137
+ # Walks `body` and returns `[Summary, edges]`.
138
+ def run(body)
139
+ walk(body)
140
+ summary = Summary.new(
141
+ bundles: @bundles, declared_bundles: @declared_bundles,
142
+ exhaustive: @causes.empty?, causes: @causes
143
+ )
144
+ [summary, @edges]
145
+ end
146
+
147
+ private
148
+
149
+ def add(origin, labels)
150
+ return if labels.empty?
151
+
152
+ @bundles[origin] = @bundles.key?(origin) ? @bundles[origin].join(labels) : labels
153
+ end
154
+
155
+ def add_declared(origin, labels)
156
+ return if labels.empty?
157
+
158
+ @declared_bundles[origin] = @declared_bundles.key?(origin) ? @declared_bundles[origin].join(labels) : labels
159
+ end
160
+
161
+ def taint(cause, detail = nil)
162
+ @causes << [cause, detail]
163
+ end
164
+
165
+ def walk(node)
166
+ return unless node.is_a?(Prism::Node)
167
+ return if unit_boundary?(node)
168
+
169
+ visit(node)
170
+ node.rigor_each_child { |child| walk(child) }
171
+ end
172
+
173
+ # A nested unit is recorded and NOT descended into: its body belongs to its own summary, and the
174
+ # enclosing method gets only the `mutate.static` of having defined it.
175
+ def unit_boundary?(node)
176
+ case node
177
+ when Prism::DefNode
178
+ @nested << [node.name.to_s, !node.receiver.nil?, node.body, node.parameters]
179
+ true
180
+ when Prism::CallNode
181
+ declared = self.class.define_method_unit(node)
182
+ return false unless declared
183
+
184
+ @nested << declared
185
+ add(DEFINE_METHOD, MUTATE_STATIC)
186
+ true
187
+ else
188
+ false
189
+ end
190
+ end
191
+
192
+ def visit(node) # rubocop:disable Metrics/CyclomaticComplexity
193
+ case node
194
+ when Prism::CallNode then visit_call(node)
195
+ when Prism::XStringNode, Prism::InterpolatedXStringNode
196
+ add(XSTRING, IO_PROCESS)
197
+ when Prism::GlobalVariableReadNode
198
+ add(GVAR_READ, GLOBAL_READ) unless FRAME_LOCAL_GLOBALS.include?(node.name.to_s)
199
+ when Prism::GlobalVariableWriteNode, Prism::GlobalVariableOperatorWriteNode,
200
+ Prism::GlobalVariableOrWriteNode, Prism::GlobalVariableAndWriteNode
201
+ add(GVAR_WRITE, GLOBAL_WRITE)
202
+ when Prism::ClassVariableReadNode
203
+ add(CVAR_READ, GLOBAL_READ)
204
+ when Prism::ClassVariableWriteNode, Prism::ClassVariableOperatorWriteNode,
205
+ Prism::ClassVariableOrWriteNode, Prism::ClassVariableAndWriteNode
206
+ add(CVAR_WRITE, MUTATE_STATIC)
207
+ when Prism::InstanceVariableWriteNode, Prism::InstanceVariableOperatorWriteNode,
208
+ Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode
209
+ add(IVAR_WRITE, @singleton ? MUTATE_STATIC : MUTATE_SELF)
210
+ when Prism::AliasMethodNode, Prism::AliasGlobalVariableNode
211
+ add(ALIAS, MUTATE_STATIC)
212
+ when Prism::UndefNode
213
+ add(UNDEF, MUTATE_STATIC)
214
+ when Prism::IndexOperatorWriteNode, Prism::IndexOrWriteNode, Prism::IndexAndWriteNode,
215
+ Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode
216
+ classify_mutation(node.receiver)
217
+ end
218
+ end
219
+
220
+ def visit_call(node)
221
+ record = @calls[node]
222
+ attribute(node, record)
223
+ plugin = attribute_plugin(node, record)
224
+ envelope = import_envelope(node, record)
225
+ # A DISCHARGING plugin row bounds the site exactly as an imported envelope does (ADR-103 WD6): a
226
+ # first-party bundled plugin's framework-derived attribution is a trusted claim about a callee the
227
+ # analyzer will never read, so "the receiver was Dynamic" and "no project definition answered" are
228
+ # both already accounted for. `Rails.env` on an app with no Rails RBS is the measured case — the
229
+ # row says what it does, and a `dynamic-receiver` taint beside it would be noise that never clears.
230
+ bound = envelope || (plugin&.discharge? ? plugin : nil)
231
+ visit_uncatalogued(node, record, bound) unless claimed_by_catalogue?(node, record)
232
+ visit_block_argument(node)
233
+ end
234
+
235
+ # The **plugin stratum** (#387; ADR-103 WD6 / WD10): what the plugin that models a framework says
236
+ # this call does.
237
+ #
238
+ # It runs beside the catalogue and beside the project's own `effects.attribution:`, for the reason
239
+ # both of those do: three different authorities can each have something true to say about one call,
240
+ # and a summary that reported only the nearest would be less honest, not simpler. What separates it
241
+ # from {#attribute} is the *taint*: a first-party bundled plugin's row is a discharging stratum, so
242
+ # the site stays exhaustive; a third-party plugin's is a claim and taints exactly like the project's
243
+ # own table.
244
+ #
245
+ # Three receiver shapes, tried nearest-syntax first — a written receiver path (`Rails.cache.read`),
246
+ # a receiver rooted at implicit self inside a framework class (`session[:x] = 1`), and the receiver's
247
+ # class through the project's inheritance chain (`user.save`).
248
+ # @return [PluginFacts::Row, nil] the row that claimed this call, for {#visit_call} to read as a bound
249
+ def attribute_plugin(node, record)
250
+ return nil unless @plugin_facts.attributions?
251
+
252
+ row = plugin_row(node, record)
253
+ return nil if row.nil?
254
+
255
+ labels = row.narrow ? Narrowing.apply(row.narrow, node) : row.labels
256
+ return row if labels.nil? || labels.empty?
257
+
258
+ add_declared(Origin.plugin(row.key), labels)
259
+ # A row may discharge AND still taint: `render` states exactly what the CONTROLLER does and says
260
+ # nothing about the template, which is not an effect unit yet (ADR-103 WD11).
261
+ taint(row.taint, row.key) if row.taint
262
+ taint("plugin-attribution", row.key) unless row.discharge?
263
+ row
264
+ end
265
+
266
+ def plugin_row(node, record)
267
+ receiver = node.receiver
268
+ selector = node.name.to_s
269
+ path = receiver_path(receiver)
270
+ # Nearest-syntax first, and every shape is TRIED rather than selected: `Rails.cache.read` is a
271
+ # receiver path and nothing else, but `ActiveRecord::Base.connection.execute` is both a receiver
272
+ # path (which no plugin rows) and a call on the result of `ActiveRecord::Base.connection` (which
273
+ # rigor-activerecord does row). Returning on the first shape that *applied* rather than the first
274
+ # that *matched* silently lost the second.
275
+ (path && @plugin_facts.path_row(path, selector)) ||
276
+ (path && @plugin_facts.self_path_row(path, selector, @owner_class)) ||
277
+ class_row_for(node, record, selector) ||
278
+ @plugin_facts.result_row(producer_class(receiver), selector)
279
+ end
280
+
281
+ def class_row_for(node, record, selector)
282
+ owner, singleton = envelope_target(node, record)
283
+ @plugin_facts.class_row(owner, singleton, selector)
284
+ end
285
+
286
+ # The class whose call produced this receiver, for an `on_result:` row: the constant in
287
+ # `UserMailer.welcome(u).deliver_now`, the receiver class the typer had otherwise. Nil unless the
288
+ # receiver is itself a call with a receiver — a bare local variable says nothing about what made it.
289
+ def producer_class(receiver)
290
+ return nil unless receiver.is_a?(Prism::CallNode)
291
+
292
+ inner = receiver.receiver
293
+ return nil if inner.nil?
294
+
295
+ Source::ConstantPath.qualified_name(inner) || @calls[receiver]&.receiver_class
296
+ end
297
+
298
+ # The receiver expression as the syntax spells it — `"Rails.cache"`, `"self.flash.now"` — or nil when
299
+ # the receiver is not a chain of argument-less sends off a constant or off implicit self. Arguments
300
+ # and blocks disqualify a link: `Rails.cache(x).read` is not the `Rails.cache` a row names, and
301
+ # pretending otherwise would put a proven-looking label on a call nobody wrote.
302
+ def receiver_path(node)
303
+ return nil unless node.is_a?(Prism::CallNode)
304
+ return nil unless node.arguments.nil? && node.block.nil?
305
+
306
+ inner = node.receiver
307
+ head =
308
+ if inner.nil? || inner.is_a?(Prism::SelfNode) then SELF_PATH_HEAD
309
+ else
310
+ Source::ConstantPath.qualified_name(inner) || receiver_path(inner)
311
+ end
312
+ head && "#{head}.#{node.name}"
313
+ end
314
+
315
+ # The **declared lane at a call site** (#386; ADR-103 WD6). If the callee's own declaration carries
316
+ # an envelope, the bound joins the caller's `≤` lane under an `envelope:` origin, and the returned
317
+ # value is what tells the uncatalogued path the site is bounded rather than unknown.
318
+ #
319
+ # The carrier is nominal and *static*: the class the syntax names for a constant-path receiver, the
320
+ # class the typer projected the receiver to otherwise, and this unit's own class for an
321
+ # implicit-self call — which is the only reason `owner_class` is passed in at all, since
322
+ # {#catalog_target} spells a receiver-less call `Kernel#name`.
323
+ #
324
+ # It runs BESIDE the catalogue, like {#attribute}: a catalogued row states what Ruby's surface
325
+ # proves, an envelope states what the callee promises, and a call that is both reads as both (the
326
+ # rendering rule drops a declared label the proven lane already admits, so the pair never prints
327
+ # twice).
328
+ def import_envelope(node, record)
329
+ return nil if @envelopes.empty?
330
+
331
+ owner, singleton = envelope_target(node, record)
332
+ return nil if owner.nil?
333
+
334
+ envelope = @envelopes[owner, singleton, node.name.to_s]
335
+ return nil if envelope.nil?
336
+
337
+ add_declared(Origin.envelope("#{owner}#{singleton ? '.' : '#'}#{node.name}"), envelope.bound)
338
+ envelope
339
+ end
340
+
341
+ # `[owner, singleton]` for the envelope lookup, or `[nil, false]`. Differs from {#catalog_target}
342
+ # in exactly one place, and that place is the point of the method: a receiver-less call resolves
343
+ # against self's ancestry first, so the carrier is the enclosing unit's class rather than the
344
+ # `Kernel` the catalogue reads it as.
345
+ def envelope_target(node, record)
346
+ owner, singleton, implicit = catalog_target(node, record)
347
+ return [@owner_class, @singleton] if implicit
348
+
349
+ [owner, singleton]
350
+ end
351
+
352
+ # The project's own `effects.attribution:` table, consulted on the same `(owner, selector)` the
353
+ # catalogue is looked up under ({Attribution}).
354
+ #
355
+ # It runs BESIDE the catalogue rather than instead of it, and it never claims the call: attribution
356
+ # answers a different question in a different lane. A catalogued row says what Ruby's own surface
357
+ # proves; an attribution says what the project *claims* about a body Rigor never read, so its labels
358
+ # go to the declared lane and the site keeps a `plugin-attribution` taint — "declared this, and
359
+ # possibly more" (ADR-103 WD6). A call that is both catalogued and attributed honestly reads as both.
360
+ def attribute(node, record)
361
+ return if @attribution.empty?
362
+
363
+ owner, singleton, = catalog_target(node, record)
364
+ return if owner.nil?
365
+
366
+ key = catalogue_key(owner, singleton, node)
367
+ labels = @attribution[key]
368
+ return if labels.nil?
369
+
370
+ add_declared(Origin.attribution(key), labels)
371
+ taint("plugin-attribution", key)
372
+ end
373
+
374
+ # The catalogued path. Answers whether the catalogue claimed this call — a claim suppresses the
375
+ # uncatalogued reading, which is what an explicit ∅ row is for.
376
+ #
377
+ # A **row** is authoritative: it states everything that call does, and `mutates: receiver` is how it
378
+ # asks for the ownership judgment on top (`ENV["k"] = v` is `global.write` and deliberately not a
379
+ # receiver mutation; `Time#localtime` is both). A **posture** states nothing about this selector,
380
+ # so the uncatalogued path's own mutation rule applies to it unchanged.
381
+ def claimed_by_catalogue?(node, record)
382
+ owner, singleton, implicit = catalog_target(node, record)
383
+ return false if owner.nil?
384
+
385
+ entry = Catalog.default.lookup(
386
+ owner, node.name.to_s, singleton: singleton, call_node: node,
387
+ posture: posture_allowed?(node, record, implicit)
388
+ )
389
+ return false if entry.nil?
390
+
391
+ add(Origin.catalogue(catalogue_key(owner, singleton, node)), entry.labels) unless entry.labels.empty?
392
+ classify_mutation(node.receiver) if mutating_catalogued?(node, entry, owner)
393
+ push_edge(record, node.name.to_s, implicit) if keeps_project_edge?(entry, implicit)
394
+ true
395
+ end
396
+
397
+ # Whether a claimed call still contributes its project edge. Two shapes do, and the summary is
398
+ # then the union of the catalogue's reading and the project definition's:
399
+ #
400
+ # - an IMPLICIT-SELF call, because an unqualified name resolves against self's ancestry first and
401
+ # a project method of the same name wins at run time. `Kernel#format` is a real row and
402
+ # `CustomField#format` is a real method, and only the union reads both correctly. (Redmine's
403
+ # `format.cast_value(…)` is the measured case: the row alone silently cut the callee off.)
404
+ # - a POSTURE answer, which is a class default rather than a statement about this selector, so a
405
+ # core class the project reopens still propagates its override's summary.
406
+ #
407
+ # An edge that reaches no project definition is dropped by the propagator, so the cost of keeping
408
+ # one is nothing and the cost of dropping one is a missing callee.
409
+ def keeps_project_edge?(entry, implicit)
410
+ entry.posture? || implicit
411
+ end
412
+
413
+ def mutating_catalogued?(node, entry, owner)
414
+ entry.mutates_receiver? || (entry.posture? && @mutation.mutating?(node, owner))
415
+ end
416
+
417
+ def catalogue_key(owner, singleton, node)
418
+ "#{owner}#{singleton ? '.' : '#'}#{node.name}"
419
+ end
420
+
421
+ # Whether the class's default posture may answer here. Three shapes where it may not:
422
+ #
423
+ # - an implicit-self (or `self.`) call, which spells `Kernel#name` and would otherwise colour
424
+ # every unqualified call in a project body `io`;
425
+ # - a `Dynamic` receiver, where the class the typer projected to is a guess and a default read off
426
+ # it would be a proven label with nothing proving it (the `dynamic-receiver` taint is the right
427
+ # answer, and the uncatalogued path records it);
428
+ # - `send` / `call`, whose own taints are the more specific reading ({DEFERRED_SELECTORS}).
429
+ def posture_allowed?(node, record, implicit)
430
+ !implicit && !record&.dynamic && !DEFERRED_SELECTORS.include?(node.name)
431
+ end
432
+
433
+ def visit_uncatalogued(node, record, bound = nil)
434
+ return visit_reflective_send(node, record) if REFLECTIVE_SEND.include?(node.name)
435
+ return taint("opaque-callable") if opaque_eval?(node)
436
+
437
+ # A write is a write whatever the receiver's type turns out to be, so ownership is classified
438
+ # first and independently of the taints below: `params[:x] = 1` on an untyped `params` is a proven
439
+ # `mutate.instance` *and* a `dynamic-receiver` taint — "this much, and possibly more".
440
+ #
441
+ # Unless a discharging plugin row already stated it. `session[:user_id] = id` is an index write on
442
+ # an object no Rails app declares a type for, so the ownership judgment can only answer "unknown"
443
+ # and taint; the row answers `mutate` + `rails.session.write`, which is both more precise and
444
+ # already trusted.
445
+ classify_mutation(node.receiver) if !plugin_bound?(bound) && @mutation.mutating?(node, record&.receiver_class)
446
+
447
+ # `opaque-callable` is checked before `dynamic-receiver` because it is the more specific reading of
448
+ # the same site: a `.call` the analyzer cannot follow to a body says *what* was unfollowable, where
449
+ # a bare Dynamic receiver says only that the receiver's class was unknown.
450
+ return taint("opaque-callable") if opaque_callable?(node, record)
451
+ # **Exhaustive by envelope** (#386). A `Dynamic` receiver whose static facet still names a class
452
+ # whose method carries an envelope is not "callee unknown": whatever object arrives, the bound its
453
+ # declaration states is the upper bound of what this call can do, and that declaration is a
454
+ # discharging stratum (ADR-103 WD6 — the project's own, contract- and Liskov-checked; or an
455
+ # accepted signature, whose types are already trusted). So the site keeps its edges into the
456
+ # project definitions the closed world knows and contributes no taint.
457
+ return record_edge(node, record, bound) if record&.dynamic && bound
458
+ return taint("dynamic-receiver", record.cause) if record&.dynamic
459
+
460
+ record_edge(node, record, bound)
461
+ end
462
+
463
+ # Whether `bound` is a plugin row rather than an imported envelope. Only the mutation judgment cares
464
+ # about the difference: an envelope bounds what a CALLEE does and says nothing about whether this
465
+ # call mutates its receiver, while a plugin row is written about the call itself.
466
+ def plugin_bound?(bound)
467
+ bound.is_a?(PluginFacts::Row)
468
+ end
469
+
470
+ # `send` / `public_send` / `__send__`: a literal selector is an ordinary edge, a computed one is the
471
+ # `dynamic-send` taint.
472
+ def visit_reflective_send(node, record)
473
+ selector = literal_selector(node.arguments&.arguments&.first)
474
+ return taint("dynamic-send") unless selector
475
+
476
+ push_edge(record, selector, node.receiver.nil?)
477
+ end
478
+
479
+ def literal_selector(node)
480
+ node.unescaped if node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
481
+ end
482
+
483
+ # A `.call` the analyzer cannot follow to a body. A call on the unit's own block parameter is
484
+ # forwarding (∅ — the block's effects are accounted at the caller's literal), and a call on a
485
+ # project object resolves as an ordinary edge.
486
+ def opaque_callable?(node, record)
487
+ receiver = node.receiver
488
+ return false unless node.name == :call && receiver
489
+ return false if receiver.is_a?(Prism::LambdaNode)
490
+ return false if receiver.is_a?(Prism::LocalVariableReadNode) && receiver.name.to_s == @block_parameter
491
+
492
+ record.nil? || record.receiver_class.nil? || %w[Proc Method].include?(record.receiver_class)
493
+ end
494
+
495
+ # The edge is recorded speculatively — only the propagator can say whether `(receiver class,
496
+ # selector)` reaches a project definition — and the taint is decided independently, from the typer's
497
+ # own verdict. The two are not exclusive: a call the typer could not resolve still names a receiver
498
+ # class, and an edge that resolves to nothing is silently dropped rather than tainting, because most
499
+ # such calls are ordinary inherited ones the catalogue simply has no row for.
500
+ def record_edge(node, record, bound = nil)
501
+ self_call = node.receiver.nil?
502
+ push_edge(record, node.name.to_s, self_call)
503
+ return unless self_call && (record.nil? || !record.resolved)
504
+ # An envelope on this unit's own class for the very selector the dispatcher declined is the
505
+ # project declaring the method and stating its bound; a discharging plugin row on the framework
506
+ # base class is the plugin doing the same for a method the framework supplies. `render` inside a
507
+ # controller is the case — the dispatcher rightly declines, because the definition is in Action
508
+ # Pack. There is nothing left to be unsure about that the bound does not already answer.
509
+ return if bound
510
+
511
+ taint("unresolved-self-call", node.name.to_s)
512
+ end
513
+
514
+ def push_edge(record, selector, self_call)
515
+ return if record.nil? || record.receiver_class.nil?
516
+
517
+ @edges << FileCollection::Edge.new(
518
+ receiver_class: record.receiver_class, kind: record.kind, selector: selector, self_call: self_call
519
+ )
520
+ end
521
+
522
+ def visit_block_argument(node)
523
+ block = node.block
524
+ return unless block.is_a?(Prism::BlockArgumentNode)
525
+
526
+ expression = block.expression
527
+ return if expression.nil? || expression.is_a?(Prism::SymbolNode)
528
+ return if expression.is_a?(Prism::LocalVariableReadNode) && expression.name.to_s == @block_parameter
529
+
530
+ taint("opaque-callable")
531
+ end
532
+
533
+ def classify_mutation(receiver)
534
+ labels = @mutation.label_for(receiver)
535
+ return taint("unknown-ownership") if labels.nil?
536
+
537
+ add(RECEIVER_MUTATION, labels)
538
+ end
539
+
540
+ # An `eval` family call carrying code rather than a block, or a bare `binding`. Both hand the
541
+ # analyzer source it will not read; the design note § 5.1 makes them a taint rather than a
542
+ # catalogue row, because there is no upper bound to state.
543
+ def opaque_eval?(node)
544
+ return node.receiver.nil? && node.arguments.nil? if node.name == :binding
545
+ return false unless EVAL_SELECTORS.include?(node.name)
546
+
547
+ positional_arity(node).positive?
548
+ end
549
+
550
+ # The class the catalogue would look this call up under, as `[owner, singleton, implicit_self]`.
551
+ # Spelled from the syntax where the syntax settles it and from the typer's receiver otherwise;
552
+ # `[nil, …]` when neither does.
553
+ def catalog_target(node, record)
554
+ receiver = node.receiver
555
+ return ["Kernel", false, true] if receiver.nil? || receiver.is_a?(Prism::SelfNode)
556
+
557
+ constant = Source::ConstantPath.qualified_name(receiver)
558
+ return [constant, !Catalog.default.object_constant?(constant), false] if constant
559
+ return NO_TARGET if record.nil? || record.receiver_class.nil?
560
+
561
+ [record.receiver_class, record.kind == :singleton, false]
562
+ end
563
+
564
+ NO_TARGET = [nil, false, false].freeze
565
+ private_constant :NO_TARGET
566
+
567
+ def positional_arity(node)
568
+ node.arguments&.arguments&.count { |argument| !argument.is_a?(Prism::KeywordHashNode) } || 0
569
+ end
570
+ end
571
+ end
572
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "unknown_label_report"
4
+
5
+ module Rigor
6
+ module Effects
7
+ # Turns the declarations a run carries into `effect.unknown-label` findings (ADR-103 WD1 / WD14;
8
+ # #384). Pure: it reads envelopes and config values and answers value objects, never diagnostics
9
+ # and never the filesystem — positioning the finding is the caller's job, because only the caller
10
+ # knows whether a `virtual:` buffer has to be mapped back to the `.rb` the author wrote in.
11
+ #
12
+ # Two producers today, one shape:
13
+ #
14
+ # - {.for_envelopes} — every `%a{pure}` / `%a{rigor:v1:effect …}` the envelope scanner read, in
15
+ # `.rbs` or through rbs-inline. One finding per (declaration, unrecognised token); a
16
+ # `def self?.x` member declares two keys off ONE annotation, so findings are deduplicated by
17
+ # where they were written rather than by which method they bind.
18
+ # - {.for_config} — a `.rigor.yml` label list: `effects.tolerated:`, `effects.labels:`, each
19
+ # `envelopes[].effect` and each `attribution:` value. The key path is a parameter rather than a
20
+ # literal precisely because there are four of them and each names a different place to edit.
21
+ module UnknownLabelCheck
22
+ # `location` is the `path:line` the declaration was written at, or nil for a config value —
23
+ # the caller resolves both. `spelling` is the author's own annotation text, which is also how a
24
+ # finding read out of a synthesized buffer is found again in the Ruby source.
25
+ Finding = Data.define(:token, :subject, :consequence, :location, :spelling, :report) do
26
+ def message = report.message(subject: subject, consequence: consequence)
27
+ end
28
+
29
+ ENVELOPE_CONSEQUENCE = "the annotation now bounds nothing"
30
+
31
+ module_function
32
+
33
+ # @param method_envelopes [Hash{String => Envelope}]
34
+ # @param class_envelopes [Hash{String => Envelope}]
35
+ # @param registry [Rigor::Effects::Registry]
36
+ # @return [Array<Finding>]
37
+ def for_envelopes(method_envelopes:, class_envelopes:, registry:)
38
+ seen = {}
39
+ declarations(method_envelopes, class_envelopes).each do |envelope|
40
+ envelope.unknown_labels.each do |token|
41
+ report = UnknownLabelReport.for(
42
+ token: token, registry: registry, siblings: envelope.declared_labels
43
+ )
44
+ next if report.nil?
45
+
46
+ seen[[envelope.location, envelope.spelling, token]] ||= envelope_finding(envelope, token, report)
47
+ end
48
+ end
49
+ seen.values
50
+ end
51
+
52
+ # @param labels [Array<String>] the list as written, in source order.
53
+ # @param key_path [String] the `.rigor.yml` key, for the message (`effects.tolerated`).
54
+ # @param consequence [String] what the degradation cost, for the message.
55
+ # @return [Array<Finding>]
56
+ def for_config(labels:, key_path:, consequence:, registry:)
57
+ tokens = Array(labels).map(&:to_s)
58
+ tokens.filter_map do |token|
59
+ report = UnknownLabelReport.for(token: token, registry: registry, siblings: tokens)
60
+ next if report.nil?
61
+
62
+ Finding.new(
63
+ token: token, subject: "`#{key_path}:` in .rigor.yml", consequence: consequence,
64
+ location: nil, spelling: nil, report: report
65
+ )
66
+ end
67
+ end
68
+
69
+ # Method-level declarations first, so a method that also carries a class-level bound reports
70
+ # against its own annotation rather than against the distributed one.
71
+ def declarations(method_envelopes, class_envelopes)
72
+ method_envelopes.values + class_envelopes.values
73
+ end
74
+
75
+ def envelope_finding(envelope, token, report)
76
+ Finding.new(
77
+ token: token, subject: "Effect envelope on #{envelope.owner_key}",
78
+ consequence: ENVELOPE_CONSEQUENCE, location: envelope.location,
79
+ spelling: envelope.spelling, report: report
80
+ )
81
+ end
82
+
83
+ private_class_method :declarations, :envelope_finding
84
+ end
85
+ end
86
+ end