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,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "label"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Effects
|
|
7
|
+
# An immutable, sorted, de-duplicated set of effect labels — the carrier of a summary lane and
|
|
8
|
+
# of an envelope's bound (ADR-103 WD1).
|
|
9
|
+
#
|
|
10
|
+
# Two sentinels bracket the lattice. {EMPTY} is the empty set: "no effects", the reading of
|
|
11
|
+
# `%a{pure}` modulo `mutate.local`. {TOP} is the unbounded / unspecified reading: it admits
|
|
12
|
+
# every label, it absorbs every join, and it is what a tag carrying an unknown label degrades
|
|
13
|
+
# to (the fail-open rule). {TOP} is NOT the set of all registered labels and is deliberately
|
|
14
|
+
# not enumerable — `top?` is the only way to tell it from {EMPTY} through `to_a`.
|
|
15
|
+
#
|
|
16
|
+
# Instances are frozen on construction and hold a frozen array, so they cross a Ractor boundary
|
|
17
|
+
# and go into a cache entry as they are.
|
|
18
|
+
class LabelSet
|
|
19
|
+
NO_LABELS = [].freeze
|
|
20
|
+
private_constant :NO_LABELS
|
|
21
|
+
|
|
22
|
+
# Build a set from any enumerable of label strings. Members are normalised (de-duplicated and
|
|
23
|
+
# sorted) so equality is structural and a snapshot rendering is deterministic.
|
|
24
|
+
#
|
|
25
|
+
# Raises `ArgumentError` on a member that does not satisfy {Label::PATTERN}: a LabelSet is a
|
|
26
|
+
# value object over the grammar, and the fail-open handling of an unrecognised *spelling* is
|
|
27
|
+
# the reader's job (it yields {TOP}), not this constructor's.
|
|
28
|
+
#
|
|
29
|
+
# `top:` is internal — it exists to build {TOP} and is not part of the surface later slices
|
|
30
|
+
# build on.
|
|
31
|
+
def initialize(labels = NO_LABELS, top: false)
|
|
32
|
+
@top = top
|
|
33
|
+
@labels = NO_LABELS
|
|
34
|
+
unless top
|
|
35
|
+
members = labels.to_a
|
|
36
|
+
members.each do |label|
|
|
37
|
+
raise ArgumentError, "not a well-formed effect label: #{label.inspect}" unless Label.valid?(label)
|
|
38
|
+
end
|
|
39
|
+
@labels = members.uniq.sort.freeze
|
|
40
|
+
end
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The unbounded set. Everything is subsumed by it; joining it with anything yields it.
|
|
45
|
+
TOP = new(NO_LABELS, top: true)
|
|
46
|
+
|
|
47
|
+
# The empty set — no effects at all.
|
|
48
|
+
EMPTY = new(NO_LABELS)
|
|
49
|
+
|
|
50
|
+
# Whether this is the unbounded sentinel {TOP}.
|
|
51
|
+
def top?
|
|
52
|
+
@top
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Whether this set records no labels. {TOP} is not empty: it records nothing because it
|
|
56
|
+
# stands for everything.
|
|
57
|
+
def empty?
|
|
58
|
+
!@top && @labels.empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The members, sorted, as a frozen array. {TOP} yields `[]` — consult {top?} first.
|
|
62
|
+
def to_a
|
|
63
|
+
@labels
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Exact membership among the recorded labels. `LabelSet.new(["io"]).include?("io.net")` is
|
|
67
|
+
# false — that question is {admits?}. {TOP} records no members, so it includes none.
|
|
68
|
+
def include?(label)
|
|
69
|
+
@labels.include?(label)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Whether some member of this set subsumes `label`. {TOP} admits every well-formed label.
|
|
73
|
+
def admits?(label)
|
|
74
|
+
return Label.valid?(label) if @top
|
|
75
|
+
|
|
76
|
+
@labels.any? { |member| Label.subsumes?(member, label) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Union. {TOP} absorbs: a join involving it is {TOP}.
|
|
80
|
+
#
|
|
81
|
+
# A join that adds nothing returns `self` **without allocating**. That is the common case wherever
|
|
82
|
+
# sets are joined in a loop — the propagator's fixpoint re-joins every edge on every visit — and the
|
|
83
|
+
# sets are single-digit-sized, so the containment scan is cheaper than the construction it avoids.
|
|
84
|
+
def join(other)
|
|
85
|
+
return TOP if @top || other.top?
|
|
86
|
+
return other if @labels.empty?
|
|
87
|
+
|
|
88
|
+
others = other.to_a
|
|
89
|
+
return self if others.empty?
|
|
90
|
+
return self if others.all? { |label| @labels.include?(label) }
|
|
91
|
+
|
|
92
|
+
self.class.new(@labels + others)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The members `other` does not already admit — the **declared lane's rendering rule**. A declared
|
|
96
|
+
# `io.net.http` beside a proven `io.net` says nothing the proven lane did not already say more
|
|
97
|
+
# strongly, so a renderer drops it rather than printing `[io.net] ≤ [io.net.http]`, which reads as
|
|
98
|
+
# two facts where there is one. Applied where output is produced, never to the table: the lanes
|
|
99
|
+
# themselves stay raw, because a later join has to see what was actually declared.
|
|
100
|
+
def excluding_subsumed_by(other)
|
|
101
|
+
return self if @top || other.empty?
|
|
102
|
+
|
|
103
|
+
kept = @labels.reject { |label| other.admits?(label) }
|
|
104
|
+
return self if kept.length == @labels.length
|
|
105
|
+
|
|
106
|
+
kept.empty? ? EMPTY : self.class.new(kept)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Whether every member of this set is admitted by `bound_set` — the envelope check, modulo
|
|
110
|
+
# the policy discharge that happens at judgment time. {TOP} is bounded only by {TOP}.
|
|
111
|
+
def subsumed_by?(bound_set)
|
|
112
|
+
return true if bound_set.top?
|
|
113
|
+
return false if @top
|
|
114
|
+
|
|
115
|
+
@labels.all? { |label| bound_set.admits?(label) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def ==(other)
|
|
119
|
+
return true if equal?(other)
|
|
120
|
+
|
|
121
|
+
other.is_a?(LabelSet) && other.top? == @top && other.to_a == @labels
|
|
122
|
+
end
|
|
123
|
+
alias eql? ==
|
|
124
|
+
|
|
125
|
+
def hash
|
|
126
|
+
[self.class, @top, @labels].hash
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def inspect
|
|
130
|
+
return "#<Rigor::Effects::LabelSet TOP>" if @top
|
|
131
|
+
|
|
132
|
+
"#<Rigor::Effects::LabelSet #{@labels.join(', ')}>"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "envelope"
|
|
4
|
+
require_relative "envelope_check"
|
|
5
|
+
require_relative "method_key"
|
|
6
|
+
require_relative "path_finder"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Effects
|
|
10
|
+
# Judges an override against the envelope it inherits (ADR-103 WD1 / WD14; #386). The one place
|
|
11
|
+
# `effect.liskov-widened` is decided.
|
|
12
|
+
#
|
|
13
|
+
# An envelope is a contract about a method, and Ruby's `class PgRepo < Repo` says a `PgRepo` is
|
|
14
|
+
# usable wherever a `Repo` is. So a bound written on `Repo#find` binds `PgRepo#find` too:
|
|
15
|
+
# **implementations may be purer than the bound they inherit, never less pure.** That is Liskov
|
|
16
|
+
# inclusion applied to the second dimension, and it is what makes the declared lane's nominal carrier
|
|
17
|
+
# (#386, {EnvelopeIndex}) honest — a caller that imports `≤ io.db` from a `Repo`-typed receiver is
|
|
18
|
+
# entitled to that bound whichever subclass actually arrives.
|
|
19
|
+
#
|
|
20
|
+
# Two comparisons, and an override is subject to exactly one of them:
|
|
21
|
+
#
|
|
22
|
+
# - **Proven against the inherited bound** — the override declares nothing of its own, so what it
|
|
23
|
+
# *does* is what the ancestor's bound has to admit. It reads the same lane
|
|
24
|
+
# {EnvelopeCheck} reads, for the same reasons: the proven closure, undischarged per policy,
|
|
25
|
+
# `mutate.local` tolerated, taint ignored.
|
|
26
|
+
# - **Declared against the inherited bound** — the override declares its own envelope, and a bound
|
|
27
|
+
# wider than the one it inherits is a Liskov violation in the *declaration*, before any body is
|
|
28
|
+
# consulted. Two authored bounds compared by subsumption; nothing proven enters it.
|
|
29
|
+
#
|
|
30
|
+
# The split is exclusive on purpose. An override that declares its own envelope is already held to
|
|
31
|
+
# that envelope by `effect.envelope-exceeded`, so running the proven comparison too would put two
|
|
32
|
+
# diagnostics on one line for one label. What the author asserted is the thing Liskov has to judge;
|
|
33
|
+
# whether the body honours the assertion is the other rule's question.
|
|
34
|
+
#
|
|
35
|
+
# Both-sides-authored, in the [ADR-35](../../../docs/adr/35-override-signature-compatibility.md)
|
|
36
|
+
# sense: nothing fires unless an author wrote an envelope on the ancestor. That is the accepted
|
|
37
|
+
# construction the false-positive budget is spent under — a firing is never unsolicited.
|
|
38
|
+
#
|
|
39
|
+
# **Nominal subclassing only.** An included module's method is not an override in this slice: Ruby's
|
|
40
|
+
# ancestry puts an includer's own `def` *ahead* of the module's rather than under it, and the
|
|
41
|
+
# substitutability argument that licenses the check is the subclass one. The `superclasses` table is
|
|
42
|
+
# the collector's own, as-written and resolved here exactly as {Propagator} resolves it, so the
|
|
43
|
+
# relation the check reads and the closed world the proven lane travels can never disagree.
|
|
44
|
+
module LiskovCheck
|
|
45
|
+
# One (override, exceeding label) pair.
|
|
46
|
+
#
|
|
47
|
+
# `own_envelope` is the override's own bound when there is one, which is also what selects the
|
|
48
|
+
# message variant: present means the declaration-level comparison produced this finding, nil means
|
|
49
|
+
# the proven one. `chain` / `origin` explain the proven variant and are nil for the other, which
|
|
50
|
+
# has no path to walk — a declaration is not proved by anything.
|
|
51
|
+
Finding = Data.define(:key, :label, :ancestor_key, :ancestor_envelope, :own_envelope, :path, :line,
|
|
52
|
+
:chain, :origin)
|
|
53
|
+
|
|
54
|
+
NO_FINDINGS = [].freeze
|
|
55
|
+
private_constant :NO_FINDINGS
|
|
56
|
+
|
|
57
|
+
module_function
|
|
58
|
+
|
|
59
|
+
# @param table [EffectTable] the run's propagated graph.
|
|
60
|
+
# @param superclasses [Hash{String => Array<String>}] the collector's as-written superclass
|
|
61
|
+
# candidate lists (`FileCollection#superclasses`).
|
|
62
|
+
# @param method_envelopes [Hash{String => Envelope}] per-method envelopes, as written.
|
|
63
|
+
# @param class_envelopes [Hash{String => Envelope}] class- / module-level envelopes, to distribute.
|
|
64
|
+
# @param config_envelopes [Hash{String => Envelope}] `effects.envelopes:` entries already resolved
|
|
65
|
+
# to the classes they select.
|
|
66
|
+
# @param positions [EnvelopeCheck::Positions] where the override's `def` is.
|
|
67
|
+
# @param apply_tolerated [Boolean] false judges against `proven` — `--no-tolerated-effects`.
|
|
68
|
+
# @return [Array<Finding>] sorted by position then key then label.
|
|
69
|
+
def run(table:, superclasses:, method_envelopes:, class_envelopes:, config_envelopes: {},
|
|
70
|
+
positions: EnvelopeCheck::Positions.empty, apply_tolerated: true)
|
|
71
|
+
# The distributed strata, over a base of the raw per-method annotations: a base class whose method
|
|
72
|
+
# exists only in `.rbs` — an abstract `def find: (Integer) -> User` with no Ruby body — has no
|
|
73
|
+
# key in the table and so no distributed entry, and its bound is exactly the one an override
|
|
74
|
+
# inherits. Distribution wins on collision, which is the same value for a key that has both.
|
|
75
|
+
envelopes = method_envelopes.merge(
|
|
76
|
+
EnvelopeCheck.distribute(table, method_envelopes, class_envelopes, config_envelopes)
|
|
77
|
+
)
|
|
78
|
+
return NO_FINDINGS if envelopes.empty?
|
|
79
|
+
|
|
80
|
+
parents = parent_map(table, superclasses)
|
|
81
|
+
return NO_FINDINGS if parents.empty?
|
|
82
|
+
|
|
83
|
+
findings = []
|
|
84
|
+
keys = table.keys
|
|
85
|
+
keys.each { |key| collect(findings, table, key, envelopes, parents, positions, apply_tolerated) }
|
|
86
|
+
findings.sort_by { |f| [f.path.to_s, f.line, f.key, f.label] }.freeze
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# `{child class => parent class}`, one parent per child.
|
|
90
|
+
#
|
|
91
|
+
# An ancestry name is recorded as written, so `class Loud < Base` inside `module Tracer` arrives as
|
|
92
|
+
# the candidate list Ruby's own lexical lookup would try, most-qualified first. The most-qualified
|
|
93
|
+
# candidate the project actually defines wins — the same rule {Propagator::Index#build_descendants}
|
|
94
|
+
# applies, and for the same reason: without it `A::Base` and `B::Base` would share the bare spelling
|
|
95
|
+
# `Base` and an unrelated class's envelope would bind an override that never inherited it.
|
|
96
|
+
def parent_map(table, superclasses)
|
|
97
|
+
return {} if superclasses.nil? || superclasses.empty?
|
|
98
|
+
|
|
99
|
+
known = table.keys.filter_map { |key| MethodKey.owner(key) }.to_set
|
|
100
|
+
superclasses.each_with_object({}) do |(child, candidates), out|
|
|
101
|
+
parent = Array(candidates).find { |candidate| known.include?(candidate) }
|
|
102
|
+
out[child] = parent if parent && parent != child
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def collect(findings, table, key, envelopes, parents, positions, apply_tolerated)
|
|
107
|
+
class_name, separator, selector = MethodKey.split(key)
|
|
108
|
+
return if class_name.nil?
|
|
109
|
+
|
|
110
|
+
ancestor_key = inherited_key(class_name, separator, selector, envelopes, parents)
|
|
111
|
+
return if ancestor_key.nil?
|
|
112
|
+
|
|
113
|
+
inherited = envelopes.fetch(ancestor_key)
|
|
114
|
+
own = envelopes[key]
|
|
115
|
+
position = positions.for(key)
|
|
116
|
+
if own && !own.top?
|
|
117
|
+
collect_declared(findings, key, ancestor_key, inherited, own, position)
|
|
118
|
+
else
|
|
119
|
+
collect_proven(findings, table, key, ancestor_key, inherited, position, apply_tolerated)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The nearest ancestor whose own method key carries an envelope. Nearest wins for the same reason
|
|
124
|
+
# it does among strata: a bound written closer to the override is the more specific statement about
|
|
125
|
+
# it, and a grandparent's bound is already binding on the parent that sits between them.
|
|
126
|
+
def inherited_key(class_name, separator, selector, envelopes, parents)
|
|
127
|
+
seen = Set.new([class_name])
|
|
128
|
+
current = parents[class_name]
|
|
129
|
+
while current && seen.add?(current)
|
|
130
|
+
candidate = "#{current}#{separator}#{selector}"
|
|
131
|
+
return candidate if envelopes.key?(candidate) && !envelopes.fetch(candidate).top?
|
|
132
|
+
|
|
133
|
+
current = parents[current]
|
|
134
|
+
end
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def collect_proven(findings, table, key, ancestor_key, inherited, position, apply_tolerated)
|
|
139
|
+
entry = table[key]
|
|
140
|
+
return if entry.nil?
|
|
141
|
+
|
|
142
|
+
exceeding = inherited.exceeded_by(apply_tolerated ? entry.undischarged : entry.proven)
|
|
143
|
+
exceeding.each do |label|
|
|
144
|
+
trail = PathFinder.shortest(table, symbol: key, label: label)
|
|
145
|
+
findings << Finding.new(
|
|
146
|
+
key: key, label: label, ancestor_key: ancestor_key, ancestor_envelope: inherited,
|
|
147
|
+
own_envelope: nil, path: position.first, line: position.last,
|
|
148
|
+
chain: trail&.chain || [key].freeze, origin: trail&.origin
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Two authored bounds, compared by subsumption alone. `mutate.local` is tolerated here as it is
|
|
154
|
+
# everywhere, so declaring it under an inherited `%a{pure}` is not a widening.
|
|
155
|
+
def collect_declared(findings, key, ancestor_key, inherited, own, position)
|
|
156
|
+
own.bound.to_a.reject { |label| inherited.tolerates?(label) }.each do |label|
|
|
157
|
+
findings << Finding.new(
|
|
158
|
+
key: key, label: label, ancestor_key: ancestor_key, ancestor_envelope: inherited,
|
|
159
|
+
own_envelope: own, path: position.first, line: position.last, chain: nil, origin: nil
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private_class_method :parent_map, :collect, :inherited_key, :collect_proven, :collect_declared
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../source/node_children"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Effects
|
|
9
|
+
# Which of a method body's locals the frame **owns** — freshly allocated here and never let out
|
|
10
|
+
# (ADR-103 WD4; the proof obligations are `control-flow-analysis.md` § Proof obligations).
|
|
11
|
+
#
|
|
12
|
+
# Ruby has no by-ref parameters, so `mutate.local` cannot mean "a write into an out-parameter" as it
|
|
13
|
+
# does in PHP. It means the mutated receiver is invisible to the caller, and that is an ownership
|
|
14
|
+
# question: a local whose every assignment allocates (`[]`, `{}`, `""`, `.new`, `.dup`) and which never
|
|
15
|
+
# escapes the body is one no caller can observe being mutated.
|
|
16
|
+
#
|
|
17
|
+
# The analysis is deliberately **flow-insensitive and whole-body**: a local that escapes anywhere
|
|
18
|
+
# disqualifies, even if the escape happens after the mutation. That is strictly more conservative than
|
|
19
|
+
# the "escaped before the mutating call" reading, which is the direction false positives are budgeted
|
|
20
|
+
# in ([ADR-5](../adr/5-robustness-principle.md)) — an unproven local mutation becomes an
|
|
21
|
+
# `unknown-ownership` taint, never a proven `mutate` label.
|
|
22
|
+
#
|
|
23
|
+
# This is the tracer slice's approximation, not the eventual answer. `ClosureEscapeAnalyzer` answers a
|
|
24
|
+
# different question (fact retention, not "does the code contain") and is deliberately left alone.
|
|
25
|
+
module LocalOwnership
|
|
26
|
+
# Assignment right-hand sides that witness a fresh allocation. `.new` and `.dup` / `.clone` follow
|
|
27
|
+
# [ADR-76](../adr/76-effect-modeling-freeze-dup-shape-preservation.md)'s reading of `dup` as the
|
|
28
|
+
# allocation witness.
|
|
29
|
+
ALLOCATING_SELECTORS = %i[new dup clone].to_set.freeze
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# The set of frame-owned local names in `body`, given the method's parameter names (a parameter is
|
|
34
|
+
# never frame-owned — the caller holds the same object, so mutating it is `mutate.instance`).
|
|
35
|
+
def owned(body, parameter_names)
|
|
36
|
+
return Set.new if body.nil?
|
|
37
|
+
|
|
38
|
+
assignments = {}
|
|
39
|
+
escaped = Set.new
|
|
40
|
+
collect(body, assignments, escaped)
|
|
41
|
+
escaped.merge(trailing_reads(body))
|
|
42
|
+
assignments.filter_map do |name, values|
|
|
43
|
+
next if escaped.include?(name) || parameter_names.include?(name)
|
|
44
|
+
|
|
45
|
+
name if values.all? { |value| allocation?(value) }
|
|
46
|
+
end.to_set
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Whether `node` is an expression that allocates a fresh object this frame is the sole holder of.
|
|
50
|
+
def allocation?(node)
|
|
51
|
+
case node
|
|
52
|
+
when Prism::ArrayNode, Prism::HashNode, Prism::StringNode, Prism::InterpolatedStringNode,
|
|
53
|
+
Prism::LambdaNode
|
|
54
|
+
true
|
|
55
|
+
when Prism::CallNode
|
|
56
|
+
ALLOCATING_SELECTORS.include?(node.name) || unary_plus_string?(node)
|
|
57
|
+
else
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# `+""` — the frozen-string-literal era's spelling of "a fresh mutable String".
|
|
63
|
+
def unary_plus_string?(node)
|
|
64
|
+
node.name == :+@ && node.receiver.is_a?(Prism::StringNode)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def collect(node, assignments, escaped)
|
|
68
|
+
return unless node.is_a?(Prism::Node)
|
|
69
|
+
|
|
70
|
+
record_assignment(node, assignments, escaped)
|
|
71
|
+
record_escapes(node, escaped)
|
|
72
|
+
node.rigor_each_child { |child| collect(child, assignments, escaped) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def record_assignment(node, assignments, escaped)
|
|
76
|
+
case node
|
|
77
|
+
when Prism::LocalVariableWriteNode
|
|
78
|
+
(assignments[node.name.to_s] ||= []) << node.value
|
|
79
|
+
# `y = x` hands the same object to a second name; neither can be proven frame-private cheaply.
|
|
80
|
+
escaped << node.value.name.to_s if node.value.is_a?(Prism::LocalVariableReadNode)
|
|
81
|
+
when Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode,
|
|
82
|
+
Prism::LocalVariableAndWriteNode, Prism::LocalVariableTargetNode
|
|
83
|
+
# Not an allocation, and a multi-assign target's value is not statically one either: record a
|
|
84
|
+
# nil right-hand side so the all-allocations test fails.
|
|
85
|
+
(assignments[node.name.to_s] ||= []) << nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# An escape is any position from which a caller could later reach the object: a call argument (the
|
|
90
|
+
# callee may store it), the right-hand side of a write to state that outlives the frame, an element
|
|
91
|
+
# of a constructed collection, or an explicit `return`.
|
|
92
|
+
def record_escapes(node, escaped)
|
|
93
|
+
case node
|
|
94
|
+
when Prism::CallNode
|
|
95
|
+
node.arguments&.arguments&.each { |argument| note_read(argument, escaped) }
|
|
96
|
+
note_read(node.block.expression, escaped) if node.block.is_a?(Prism::BlockArgumentNode)
|
|
97
|
+
when Prism::ReturnNode
|
|
98
|
+
node.arguments&.arguments&.each { |argument| note_read(argument, escaped) }
|
|
99
|
+
when Prism::ArrayNode
|
|
100
|
+
node.elements.each { |element| note_read(element, escaped) }
|
|
101
|
+
else
|
|
102
|
+
note_read(stored_value(node), escaped)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The value half of a write into state that outlives the frame, or of a hash entry. nil for every
|
|
107
|
+
# other node, which {note_read} ignores.
|
|
108
|
+
def stored_value(node)
|
|
109
|
+
case node
|
|
110
|
+
when Prism::AssocNode, Prism::InstanceVariableWriteNode, Prism::ClassVariableWriteNode,
|
|
111
|
+
Prism::GlobalVariableWriteNode, Prism::ConstantWriteNode
|
|
112
|
+
node.value
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def note_read(node, escaped)
|
|
117
|
+
escaped << node.name.to_s if node.is_a?(Prism::LocalVariableReadNode)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A body whose value is a bare local read hands that local to the caller. Only the tail matters —
|
|
121
|
+
# every other position is covered by {record_escapes}.
|
|
122
|
+
def trailing_reads(body)
|
|
123
|
+
statements = body.is_a?(Prism::StatementsNode) ? body.body : [body]
|
|
124
|
+
last = statements.last
|
|
125
|
+
last.is_a?(Prism::LocalVariableReadNode) ? [last.name.to_s] : []
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private_class_method :collect, :record_assignment, :record_escapes, :stored_value, :note_read,
|
|
129
|
+
:trailing_reads
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Effects
|
|
5
|
+
# The spelling of an effect unit's key, in one place (ADR-103 WD14).
|
|
6
|
+
#
|
|
7
|
+
# `Owner#instance_method`, `Owner.singleton_method`, `<toplevel>#bare_def`. The separator is the FIRST
|
|
8
|
+
# `#` or `.` in the string, which is what makes `Net::HTTP.get` split at the dot rather than inside the
|
|
9
|
+
# namespace — a namespace carries `::`, never a bare dot, and a selector carries neither.
|
|
10
|
+
#
|
|
11
|
+
# This module exists because the split is now written on both sides of a contract: the scanner spells a
|
|
12
|
+
# key, and `effects.attribution:` in `.rigor.yml` names one. A key the loader accepts and the scanner
|
|
13
|
+
# would never produce is a table that silently matches nothing.
|
|
14
|
+
module MethodKey
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# @return [Array(String, String, String), nil] `[owner, separator, selector]`, or nil when `key` is
|
|
18
|
+
# not a method key at all.
|
|
19
|
+
def split(key)
|
|
20
|
+
text = key.to_s
|
|
21
|
+
index = text.index("#") || text.index(".")
|
|
22
|
+
return nil if index.nil? || index.zero? || index == text.length - 1
|
|
23
|
+
|
|
24
|
+
[text[0, index], text[index], text[(index + 1)..]]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def valid?(key)
|
|
28
|
+
parts = split(key)
|
|
29
|
+
return false if parts.nil?
|
|
30
|
+
|
|
31
|
+
parts.none? { |part| part.match?(/\s/) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The owner half, or nil. What `keys_by_class`-shaped groupings ask for.
|
|
35
|
+
def owner(key)
|
|
36
|
+
split(key)&.first
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../inference/mutation_widening"
|
|
6
|
+
require_relative "label_set"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Effects
|
|
10
|
+
# Decides whether a call mutates its receiver, and — when it does — which `mutate.*` label that earns
|
|
11
|
+
# (ADR-103 WD4 / WD14).
|
|
12
|
+
#
|
|
13
|
+
# Two independent questions, both answered conservatively:
|
|
14
|
+
#
|
|
15
|
+
# 1. **Is this a mutation?** Only when the selector says so beyond doubt. `[]=` and an attribute writer
|
|
16
|
+
# are writes on every receiver; `<<` and the bang family are claimed only when the typer named the
|
|
17
|
+
# receiver's class, because `n << 2` is a bit shift and `io << "x"` is output. A wrong label in the
|
|
18
|
+
# proven lane is worse than a missing one — the proven lane is the one a verdict may read (ADR-5).
|
|
19
|
+
# 2. **Who owns the receiver?** `self` and its ivars are `mutate.self` (`mutate.static` in singleton
|
|
20
|
+
# context), a class variable is `mutate.static`, a parameter is `mutate.instance`, a frame-owned
|
|
21
|
+
# local is `mutate.local`. **Anything else answers nil**, and the caller records an
|
|
22
|
+
# `unknown-ownership` taint rather than a proven bare `mutate`: Ruby's ownership is a dataflow
|
|
23
|
+
# question, and a proven parent label on a fresh-but-unproven receiver would put findings on correct
|
|
24
|
+
# code (WD14).
|
|
25
|
+
class MutationClassifier
|
|
26
|
+
# The only selectors a mutation may be claimed from without knowing the receiver's class.
|
|
27
|
+
UNIVERSAL_MUTATORS = %i[[]=].to_set.freeze
|
|
28
|
+
|
|
29
|
+
# `String`'s receiver-mutating surface. `Array` / `Hash` reuse the hand-audited sets the widening
|
|
30
|
+
# rules already maintain, cited rather than re-derived (ADR-103 WD3).
|
|
31
|
+
STRING_MUTATORS = %i[
|
|
32
|
+
<< concat replace insert prepend clear
|
|
33
|
+
upcase! downcase! capitalize! swapcase! reverse!
|
|
34
|
+
strip! lstrip! rstrip! chomp! chop! squeeze! succ! next!
|
|
35
|
+
sub! gsub! tr! tr_s! delete! slice! []=
|
|
36
|
+
].to_set.freeze
|
|
37
|
+
|
|
38
|
+
# `foo=`, and deliberately not `==` / `<=` / `!=` / `===`.
|
|
39
|
+
ATTRIBUTE_WRITER = /\A[a-z_][A-Za-z0-9_]*=\z/
|
|
40
|
+
|
|
41
|
+
LABELS = {
|
|
42
|
+
self_state: LabelSet.new(["mutate.self"]),
|
|
43
|
+
static: LabelSet.new(["mutate.static"]),
|
|
44
|
+
instance: LabelSet.new(["mutate.instance"]),
|
|
45
|
+
local: LabelSet.new(["mutate.local"])
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
def initialize(singleton:, parameters:, owned_locals:)
|
|
49
|
+
@singleton = singleton
|
|
50
|
+
@parameters = parameters
|
|
51
|
+
@owned_locals = owned_locals
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Whether `node` mutates its receiver. `receiver_class` is the class the typer projected the
|
|
55
|
+
# receiver's type to, or nil when it projected to none.
|
|
56
|
+
def mutating?(node, receiver_class)
|
|
57
|
+
name = node.name
|
|
58
|
+
return true if UNIVERSAL_MUTATORS.include?(name) || ATTRIBUTE_WRITER.match?(name.to_s)
|
|
59
|
+
|
|
60
|
+
case receiver_class
|
|
61
|
+
when "Array" then Inference::MutationWidening::ARRAY_MUTATORS.include?(name)
|
|
62
|
+
when "Hash" then Inference::MutationWidening::HASH_MUTATORS.include?(name)
|
|
63
|
+
when "String" then STRING_MUTATORS.include?(name)
|
|
64
|
+
else false
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The label a mutation of `receiver` earns, or nil when ownership is not provable.
|
|
69
|
+
def label_for(receiver)
|
|
70
|
+
LABELS[ownership(receiver)]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def ownership(receiver)
|
|
76
|
+
case receiver
|
|
77
|
+
when nil, Prism::SelfNode, Prism::InstanceVariableReadNode
|
|
78
|
+
@singleton ? :static : :self_state
|
|
79
|
+
when Prism::ClassVariableReadNode then :static
|
|
80
|
+
when Prism::LocalVariableReadNode then local_ownership(receiver.name.to_s)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def local_ownership(name)
|
|
85
|
+
return :instance if @parameters.include?(name)
|
|
86
|
+
return :local if @owned_locals.include?(name)
|
|
87
|
+
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|