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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/data/builtins/ruby_core/array.yml +11 -24
- data/data/builtins/ruby_core/enumerable.yml +4 -8
- data/data/builtins/ruby_core/exception.yml +6 -9
- data/data/builtins/ruby_core/hash.yml +13 -20
- data/data/builtins/ruby_core/io.yml +6 -9
- data/data/builtins/ruby_core/numeric.yml +2 -3
- data/data/builtins/ruby_core/proc.yml +4 -8
- data/data/builtins/ruby_core/re.yml +6 -9
- data/data/builtins/ruby_core/set.yml +2 -3
- data/data/builtins/ruby_core/string.yml +4 -7
- data/data/builtins/ruby_core/struct.yml +0 -6
- data/data/builtins/ruby_core/time.yml +3 -6
- data/data/effects/core.yml +860 -0
- data/data/effects/registry.yml +95 -0
- data/docs/handbook/07-rbs-and-extended.md +106 -5
- data/docs/manual/02-cli-reference.md +359 -2
- data/docs/manual/03-configuration.md +101 -1
- data/docs/manual/04-diagnostics.md +8 -3
- data/docs/manual/11-ci.md +37 -0
- data/docs/manual/12-caching.md +39 -0
- data/docs/manual/16-rbs-extended-annotations.md +90 -0
- data/docs/manual/18-removing-dead-code.md +326 -0
- data/docs/manual/19-effect-labels.md +671 -0
- data/docs/manual/README.md +10 -0
- data/docs/manual/ci-templates/README.md +9 -0
- data/docs/manual/plugins/rigor-actionmailer.md +5 -0
- data/docs/manual/plugins/rigor-activejob.md +47 -0
- data/docs/manual/plugins/rigor-factorybot.md +16 -0
- data/docs/manual/plugins/rigor-pundit.md +26 -0
- data/docs/manual/plugins/rigor-rails-routes.md +41 -2
- data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
- data/docs/manual/plugins/rigor-rspec.md +8 -0
- data/docs/manual/plugins/rigor-sidekiq.md +54 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
- data/lib/rigor/analysis/incremental_session.rb +140 -6
- data/lib/rigor/analysis/reachability/graph.rb +244 -0
- data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
- data/lib/rigor/analysis/reachability/project_files.rb +58 -0
- data/lib/rigor/analysis/reachability/scan.rb +241 -0
- data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
- data/lib/rigor/analysis/rule_catalog.rb +168 -3
- data/lib/rigor/analysis/run_cache_key.rb +16 -0
- data/lib/rigor/analysis/run_cache_probe.rb +69 -1
- data/lib/rigor/analysis/runner/declaration_position.rb +38 -0
- data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
- data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +93 -10
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
- data/lib/rigor/analysis/runner.rb +429 -10
- data/lib/rigor/analysis/worker_session.rb +60 -2
- data/lib/rigor/bleeding_edge.rb +22 -0
- data/lib/rigor/cache/incremental_snapshot.rb +24 -5
- data/lib/rigor/cli/check_command.rb +13 -1
- data/lib/rigor/cli/check_runner_factory.rb +4 -1
- data/lib/rigor/cli/effects_command.rb +245 -0
- data/lib/rigor/cli/effects_diff_renderer.rb +127 -0
- data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
- data/lib/rigor/cli/effects_renderer.rb +92 -0
- data/lib/rigor/cli/effects_report.rb +163 -0
- data/lib/rigor/cli/effects_snapshot_command.rb +275 -0
- data/lib/rigor/cli/unused_command.rb +288 -0
- data/lib/rigor/cli.rb +28 -1
- data/lib/rigor/configuration/severity_profile.rb +23 -0
- data/lib/rigor/configuration.rb +322 -28
- data/lib/rigor/configuration_error.rb +20 -0
- data/lib/rigor/effects/attribution.rb +76 -0
- data/lib/rigor/effects/catalog.rb +275 -0
- data/lib/rigor/effects/collector.rb +219 -0
- data/lib/rigor/effects/config_envelopes.rb +185 -0
- data/lib/rigor/effects/discharge.rb +69 -0
- data/lib/rigor/effects/effect_table.rb +92 -0
- data/lib/rigor/effects/entry_points.rb +139 -0
- data/lib/rigor/effects/envelope.rb +86 -0
- data/lib/rigor/effects/envelope_check.rb +172 -0
- data/lib/rigor/effects/envelope_index.rb +157 -0
- data/lib/rigor/effects/file_collection.rb +174 -0
- data/lib/rigor/effects/framework_units.rb +222 -0
- data/lib/rigor/effects/identity.rb +104 -0
- data/lib/rigor/effects/inline_anchor.rb +134 -0
- data/lib/rigor/effects/label.rb +77 -0
- data/lib/rigor/effects/label_intent.rb +73 -0
- data/lib/rigor/effects/label_set.rb +136 -0
- data/lib/rigor/effects/liskov_check.rb +167 -0
- data/lib/rigor/effects/local_ownership.rb +132 -0
- data/lib/rigor/effects/method_key.rb +40 -0
- data/lib/rigor/effects/mutation_classifier.rb +92 -0
- data/lib/rigor/effects/narrowing.rb +202 -0
- data/lib/rigor/effects/origin.rb +66 -0
- data/lib/rigor/effects/path_finder.rb +89 -0
- data/lib/rigor/effects/plugin_facts.rb +384 -0
- data/lib/rigor/effects/propagator.rb +335 -0
- data/lib/rigor/effects/registry.rb +200 -0
- data/lib/rigor/effects/scanner.rb +302 -0
- data/lib/rigor/effects/signature_sources.rb +90 -0
- data/lib/rigor/effects/snapshot.rb +396 -0
- data/lib/rigor/effects/snapshot_diff.rb +265 -0
- data/lib/rigor/effects/summary.rb +154 -0
- data/lib/rigor/effects/taint_cause.rb +39 -0
- data/lib/rigor/effects/unit_scan.rb +647 -0
- data/lib/rigor/effects/unknown_label_check.rb +86 -0
- data/lib/rigor/effects/unknown_label_report.rb +59 -0
- data/lib/rigor/effects.rb +47 -0
- data/lib/rigor/environment/rbs_loader.rb +34 -0
- data/lib/rigor/environment.rb +8 -1
- data/lib/rigor/flow_contribution/element.rb +1 -0
- data/lib/rigor/flow_contribution/merge_result.rb +5 -3
- data/lib/rigor/flow_contribution/merger.rb +16 -1
- data/lib/rigor/flow_contribution.rb +20 -4
- data/lib/rigor/inference/expression_typer.rb +18 -4
- data/lib/rigor/inference/pre_eval_constants.rb +189 -0
- data/lib/rigor/inference/scope_indexer.rb +15 -3
- data/lib/rigor/plugin/base.rb +28 -0
- data/lib/rigor/plugin/box.rb +18 -2
- data/lib/rigor/plugin/effect_ancestry.rb +80 -0
- data/lib/rigor/plugin/effect_attribution.rb +208 -0
- data/lib/rigor/plugin/effect_edge.rb +101 -0
- data/lib/rigor/plugin/effect_entry_points.rb +51 -0
- data/lib/rigor/plugin/first_party.rb +57 -0
- data/lib/rigor/plugin/isolation.rb +4 -1
- data/lib/rigor/plugin/manifest.rb +127 -3
- data/lib/rigor/plugin/registry.rb +55 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
- data/lib/rigor/rbs_extended.rb +188 -0
- data/lib/rigor/reflection.rb +131 -30
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +401 -29
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
- data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
- data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
- data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
- data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +68 -3
- data/sig/rigor/analysis/fact_store.rbs +1 -0
- data/sig/rigor/inference.rbs +2 -0
- data/sig/rigor/rbs_extended.rbs +6 -0
- data/sig/rigor.rbs +17 -1
- data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
- 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
|