rigortype 0.3.2 → 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.
- 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 +843 -0
- data/data/effects/registry.yml +67 -0
- data/docs/handbook/07-rbs-and-extended.md +106 -5
- data/docs/manual/02-cli-reference.md +311 -1
- data/docs/manual/03-configuration.md +84 -1
- data/docs/manual/04-diagnostics.md +14 -2
- data/docs/manual/16-rbs-extended-annotations.md +77 -0
- data/docs/manual/18-removing-dead-code.md +326 -0
- data/docs/manual/README.md +5 -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/always_truthy_condition_collector.rb +8 -0
- data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
- data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
- data/lib/rigor/analysis/check_rules.rb +62 -22
- 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 +163 -1
- data/lib/rigor/analysis/run_cache_key.rb +16 -0
- data/lib/rigor/analysis/runner/declaration_position.rb +54 -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 +68 -10
- data/lib/rigor/analysis/runner.rb +420 -10
- data/lib/rigor/analysis/severity_stamp.rb +2 -1
- data/lib/rigor/analysis/worker_session.rb +58 -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 +131 -0
- data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
- data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
- data/lib/rigor/cli/effects_renderer.rb +54 -0
- data/lib/rigor/cli/effects_report.rb +53 -0
- data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
- data/lib/rigor/cli/unused_command.rb +288 -0
- data/lib/rigor/cli.rb +16 -0
- data/lib/rigor/configuration/severity_profile.rb +23 -0
- data/lib/rigor/configuration.rb +296 -14
- data/lib/rigor/effects/attribution.rb +76 -0
- data/lib/rigor/effects/catalog.rb +275 -0
- data/lib/rigor/effects/collector.rb +182 -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 +93 -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 +160 -0
- data/lib/rigor/effects/framework_units.rb +167 -0
- data/lib/rigor/effects/identity.rb +104 -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 +335 -0
- data/lib/rigor/effects/propagator.rb +275 -0
- data/lib/rigor/effects/registry.rb +193 -0
- data/lib/rigor/effects/scanner.rb +290 -0
- data/lib/rigor/effects/signature_sources.rb +74 -0
- data/lib/rigor/effects/snapshot.rb +380 -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 +38 -0
- data/lib/rigor/effects/unit_scan.rb +572 -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/anonymous_meta_class.rb +81 -0
- data/lib/rigor/inference/expression_typer.rb +74 -17
- data/lib/rigor/inference/fork_map.rb +45 -11
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
- data/lib/rigor/inference/method_dispatcher.rb +41 -10
- data/lib/rigor/inference/optimistic_origin.rb +68 -3
- data/lib/rigor/inference/pre_eval_constants.rb +189 -0
- data/lib/rigor/inference/scope_indexer.rb +134 -31
- data/lib/rigor/inference/singleton_object_constant.rb +63 -0
- data/lib/rigor/inference/statement_evaluator.rb +25 -12
- data/lib/rigor/plugin/base.rb +24 -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 +103 -3
- data/lib/rigor/plugin/node_rule_walk.rb +6 -0
- data/lib/rigor/plugin/registry.rb +48 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
- data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
- data/lib/rigor/rbs_extended.rb +168 -0
- data/lib/rigor/reflection.rb +131 -30
- data/lib/rigor/scope.rb +62 -2
- data/lib/rigor/source/node_walker.rb +12 -0
- data/lib/rigor/type/anonymous_class_name.rb +40 -0
- data/lib/rigor/type/nominal.rb +5 -2
- data/lib/rigor/type/singleton.rb +4 -1
- 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 +385 -28
- 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-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -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/scope.rbs +4 -0
- data/sig/rigor.rbs +21 -1
- data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
- metadata +83 -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
|