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,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
require_relative "label"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Effects
|
|
9
|
+
# The effect-label vocabulary: which spellings are recognised, which have been retired, and who
|
|
10
|
+
# may open a new root (ADR-103 WD2; normative in
|
|
11
|
+
# `docs/type-specification/effect-labels.md`).
|
|
12
|
+
#
|
|
13
|
+
# The shared layer is hand-written in `data/effects/registry.yml` — Steins' v1 set verbatim,
|
|
14
|
+
# Ruby's `mutate` leaves, the proposed shared core leaves and the application-meaning roots —
|
|
15
|
+
# so a later slice or a plugin extends the data, not this Ruby. Extensions arrive through
|
|
16
|
+
# {#with}, which is the one place root ownership is enforced.
|
|
17
|
+
#
|
|
18
|
+
# A Registry is a frozen value object; {#with} returns a new one rather than mutating.
|
|
19
|
+
class Registry
|
|
20
|
+
class Error < StandardError
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# An added label whose root neither exists already nor belongs to the extender.
|
|
24
|
+
class OwnershipError < Error
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# An added label that is not well-formed under {Label::PATTERN}.
|
|
28
|
+
class InvalidLabelError < Error
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
DATA_PATH = File.expand_path("../../../data/effects/registry.yml", __dir__)
|
|
32
|
+
|
|
33
|
+
# How far a misspelling may be from a known label before {#suggest} declines to guess. Two
|
|
34
|
+
# edits catches a transposition or a dropped segment character ("io.nte", "nondet.tim")
|
|
35
|
+
# without proposing an unrelated label for a genuinely new spelling.
|
|
36
|
+
SUGGESTION_DISTANCE_CAP = 2
|
|
37
|
+
|
|
38
|
+
# The shared registry as shipped. Memoised: the YAML parse is a once-per-process cost, and
|
|
39
|
+
# nothing consumes the registry yet, so it stays lazy rather than paying at `require "rigor"`.
|
|
40
|
+
def self.default
|
|
41
|
+
@default ||= load_file(DATA_PATH)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The vocabulary one run works in: the shipped registry plus whatever `effects.labels:` opened
|
|
45
|
+
# (ADR-103 WD2 / #385). The project is the one extender that may open ANY root — listing a label in
|
|
46
|
+
# its own configuration is the vouching act — so this can only fail on a spelling `Configuration`
|
|
47
|
+
# already rejected at load, and a failure degrades to the shipped vocabulary rather than taking the
|
|
48
|
+
# run down.
|
|
49
|
+
#
|
|
50
|
+
# Memoised on the label list, because the answer is a frozen value object and every effects surface
|
|
51
|
+
# in a run asks for the same one: the envelope pass, the unknown-label check, the snapshot header.
|
|
52
|
+
#
|
|
53
|
+
# `plugin_facts` (#387) folds in every loaded plugin's `effect_labels:` FIRST, each under its own
|
|
54
|
+
# owner, so that a project's `effects.labels:` can then name a plugin-opened root and an envelope may
|
|
55
|
+
# bound `rails.activejob.enqueue` without the project having to re-declare the framework's vocabulary.
|
|
56
|
+
# The plugin layer is not memoised on its own — {PluginFacts} is already per-process — and the
|
|
57
|
+
# project layer keeps its memo keyed on the pair.
|
|
58
|
+
def self.for_configuration(configuration, plugin_facts: nil)
|
|
59
|
+
base = plugin_facts.nil? || plugin_facts.empty? ? default : plugin_facts.extend_registry(default)
|
|
60
|
+
labels = configuration.effects_labels
|
|
61
|
+
return base if labels.nil? || labels.empty?
|
|
62
|
+
|
|
63
|
+
(@extended ||= {})[[labels, base.labels]] ||= base.with(labels: labels, owner: nil)
|
|
64
|
+
rescue Error
|
|
65
|
+
default
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Build a registry from a registry-shaped YAML file. Missing or unreadable data degrades to
|
|
69
|
+
# an empty vocabulary rather than raising, matching the built-in catalogues' posture for a
|
|
70
|
+
# bare install that opted data out; every label then reads as unknown, which is fail-open.
|
|
71
|
+
def self.load_file(path)
|
|
72
|
+
raw = File.exist?(path) ? YAML.safe_load_file(path) : nil
|
|
73
|
+
raw = {} unless raw.is_a?(Hash)
|
|
74
|
+
new(
|
|
75
|
+
vocabulary_version: raw.fetch("vocabulary", 0),
|
|
76
|
+
labels: raw.fetch("labels", nil) || [],
|
|
77
|
+
retired: raw.fetch("retired", nil) || {}
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
attr_reader :vocabulary_version
|
|
82
|
+
|
|
83
|
+
def initialize(vocabulary_version:, labels:, retired: {})
|
|
84
|
+
@vocabulary_version = vocabulary_version
|
|
85
|
+
@labels = labels.map(&:to_s).uniq.sort.freeze
|
|
86
|
+
@known = build_known(@labels)
|
|
87
|
+
@roots = @known.select { |label| Label.parent(label).nil? }.sort.freeze
|
|
88
|
+
@retired = build_retired(retired)
|
|
89
|
+
freeze
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Every declared label, sorted. Implied ancestors (`email`, because `email.send` is declared)
|
|
93
|
+
# are recognised by {#known?} but are not rows of the vocabulary and are not listed here.
|
|
94
|
+
attr_reader :labels
|
|
95
|
+
|
|
96
|
+
# The roots of the vocabulary — the outermost segments {#with} treats as already owned.
|
|
97
|
+
attr_reader :roots
|
|
98
|
+
|
|
99
|
+
# Whether the vocabulary recognises `label`: an exact row, or an ancestor of one. A declared
|
|
100
|
+
# `io` is recognised because `io.net` exists, so a bound may name an interior node the data
|
|
101
|
+
# file never spells out on its own line.
|
|
102
|
+
def known?(label)
|
|
103
|
+
@known.include?(label)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The nearest recognised label to a misspelling, within {SUGGESTION_DISTANCE_CAP} edits, or
|
|
107
|
+
# `nil` when nothing is close enough. A recognised label suggests nothing — ask {#known?}
|
|
108
|
+
# first.
|
|
109
|
+
def suggest(label)
|
|
110
|
+
return nil unless Label.valid?(label)
|
|
111
|
+
return nil if known?(label)
|
|
112
|
+
|
|
113
|
+
best = nil
|
|
114
|
+
best_distance = SUGGESTION_DISTANCE_CAP + 1
|
|
115
|
+
@known.each do |candidate|
|
|
116
|
+
distance = levenshtein(label, candidate, best_distance)
|
|
117
|
+
next unless distance < best_distance
|
|
118
|
+
|
|
119
|
+
best = candidate
|
|
120
|
+
best_distance = distance
|
|
121
|
+
end
|
|
122
|
+
best
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The replacement labels for a retired spelling, or `nil` when the spelling was never
|
|
126
|
+
# retired. A rename or a removal bumps {#vocabulary_version} and records the old spelling
|
|
127
|
+
# here so a snapshot written by an older Rigor still reads.
|
|
128
|
+
def retired(label)
|
|
129
|
+
@retired[label]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# A new registry carrying `labels` on top of this one.
|
|
133
|
+
#
|
|
134
|
+
# `owner` is the identity opening a root: a plugin id, or — for a first-party plugin that
|
|
135
|
+
# models a framework — the framework root it owns. `nil` is the project, which may open any
|
|
136
|
+
# root. Every added label must either descend from a root this registry already knows or
|
|
137
|
+
# open a root equal to `owner`.
|
|
138
|
+
def with(labels:, owner:)
|
|
139
|
+
added = labels.map(&:to_s)
|
|
140
|
+
added.each do |label|
|
|
141
|
+
raise InvalidLabelError, "not a well-formed effect label: #{label.inspect}" unless Label.valid?(label)
|
|
142
|
+
|
|
143
|
+
check_ownership(label, owner)
|
|
144
|
+
end
|
|
145
|
+
self.class.new(vocabulary_version: @vocabulary_version, labels: @labels + added, retired: @retired)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def check_ownership(label, owner)
|
|
151
|
+
root = Label.root(label)
|
|
152
|
+
return if @roots.include?(root)
|
|
153
|
+
return if owner.nil?
|
|
154
|
+
return if owner == root
|
|
155
|
+
|
|
156
|
+
raise OwnershipError,
|
|
157
|
+
"#{owner.inspect} may not open the effect-label root #{root.inspect} (from #{label.inspect}); " \
|
|
158
|
+
"a non-project extender opens only the root it owns"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def build_known(labels)
|
|
162
|
+
known = labels.to_set
|
|
163
|
+
labels.each { |label| Label.ancestors(label).each { |ancestor| known << ancestor } }
|
|
164
|
+
known.freeze
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build_retired(retired)
|
|
168
|
+
retired.to_h { |spelling, replacements| [spelling.to_s, Array(replacements).map(&:to_s).freeze] }.freeze
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Bounded Levenshtein. Answers `cap + 1` — "further than the cap" — as soon as the length
|
|
172
|
+
# difference or a whole row of the matrix rules the candidate out, so scanning the vocabulary
|
|
173
|
+
# for a suggestion stays cheap.
|
|
174
|
+
def levenshtein(from, to, cap)
|
|
175
|
+
beyond = cap + 1
|
|
176
|
+
return beyond if (from.length - to.length).abs > cap
|
|
177
|
+
|
|
178
|
+
previous = (0..to.length).to_a
|
|
179
|
+
from.each_char.with_index(1) do |char, row|
|
|
180
|
+
current = [row]
|
|
181
|
+
to.each_char.with_index(1) do |other, column|
|
|
182
|
+
cost = char == other ? 0 : 1
|
|
183
|
+
current << [current[column - 1] + 1, previous[column] + 1, previous[column - 1] + cost].min
|
|
184
|
+
end
|
|
185
|
+
return beyond if current.min > cap
|
|
186
|
+
|
|
187
|
+
previous = current
|
|
188
|
+
end
|
|
189
|
+
previous.last > cap ? beyond : previous.last
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,290 @@
|
|
|
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 "envelope_index"
|
|
9
|
+
require_relative "file_collection"
|
|
10
|
+
require_relative "local_ownership"
|
|
11
|
+
require_relative "origin"
|
|
12
|
+
require_relative "framework_units"
|
|
13
|
+
require_relative "plugin_facts"
|
|
14
|
+
require_relative "summary"
|
|
15
|
+
require_relative "unit_scan"
|
|
16
|
+
|
|
17
|
+
module Rigor
|
|
18
|
+
module Effects
|
|
19
|
+
# Turns one file's AST plus the call decisions the typer recorded for it into a {FileCollection}.
|
|
20
|
+
#
|
|
21
|
+
# The scanner owns **identity** — which effect units the file defines and what each is keyed as — and
|
|
22
|
+
# delegates each unit's body to {UnitScan}. Keys follow the existing symbol tables (ADR-103 WD14):
|
|
23
|
+
# `Class#m` for an instance method, `Class.m` for a singleton one, and `<toplevel>#m` for a `def`
|
|
24
|
+
# outside any class body. Reopenings in one file join here; reopenings across files join when the
|
|
25
|
+
# runner merges the collections.
|
|
26
|
+
#
|
|
27
|
+
# Three kinds of unit exist beyond a plain `def`:
|
|
28
|
+
#
|
|
29
|
+
# - `define_method(:literal) { … }` — the block is the method's body. The def-node discovery tables
|
|
30
|
+
# skip it, so this is the minimal extension WD14 calls for, made here rather than in `ScopeIndexer`
|
|
31
|
+
# because nothing outside effects needs it yet.
|
|
32
|
+
# - `attr_reader` / `attr_writer` / `attr_accessor` — synthesised: a reader is ∅, a writer is
|
|
33
|
+
# `mutate.self`. Without them a caller's edge into an accessor would read as unresolved.
|
|
34
|
+
# - a nested `def` — its own unit under the same class, never contained in the enclosing method.
|
|
35
|
+
#
|
|
36
|
+
# **This walk exists only when collection is on.** ADR-103 WD13 prefers riding `ScopeIndexer`'s
|
|
37
|
+
# existing `def` descent; a separate walk is taken here because the scanner must also attribute each
|
|
38
|
+
# recorded call *node* to its enclosing unit, and doing that inside the indexer would put an
|
|
39
|
+
# effects-shaped concern on the hot path for every run. Off, this file is never loaded past `require`.
|
|
40
|
+
class Scanner
|
|
41
|
+
# Mirrors `Inference::ScopeIndexer::TOP_LEVEL_DEF_KEY`. Spelled again rather than required so the
|
|
42
|
+
# effects namespace does not pull the indexer in; the two are pinned together by spec.
|
|
43
|
+
TOP_LEVEL_KEY = "<toplevel>"
|
|
44
|
+
|
|
45
|
+
# Receiver-less calls in a class / module body that {#record_declaration} interprets. Everything
|
|
46
|
+
# else in a class body is out of scope in v1 — its statements run at load time, which is a unit of
|
|
47
|
+
# its own that no slice models yet.
|
|
48
|
+
DECLARATION_MACROS = %i[include prepend attr_reader attr_writer attr_accessor define_method].to_set.freeze
|
|
49
|
+
|
|
50
|
+
MUTATE_SELF = LabelSet.new(["mutate.self"])
|
|
51
|
+
private_constant :MUTATE_SELF
|
|
52
|
+
|
|
53
|
+
# A synthesised writer's summary is the same value at every `attr_accessor` in the project, so it is
|
|
54
|
+
# built once rather than per accessor.
|
|
55
|
+
WRITER_SUMMARY = Summary.new(bundles: { Origin.construct("attr-writer") => MUTATE_SELF })
|
|
56
|
+
private_constant :WRITER_SUMMARY
|
|
57
|
+
|
|
58
|
+
def self.scan(root:, path:, calls:, attribution: Attribution.empty, envelopes: EnvelopeIndex.empty,
|
|
59
|
+
plugin_facts: PluginFacts.empty)
|
|
60
|
+
new(path: path, calls: calls, attribution: attribution, envelopes: envelopes,
|
|
61
|
+
plugin_facts: plugin_facts).scan(root)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def initialize(path:, calls:, attribution: Attribution.empty, envelopes: EnvelopeIndex.empty,
|
|
65
|
+
plugin_facts: PluginFacts.empty)
|
|
66
|
+
@path = path
|
|
67
|
+
@calls = calls
|
|
68
|
+
@attribution = attribution
|
|
69
|
+
@envelopes = envelopes
|
|
70
|
+
@plugin_facts = plugin_facts
|
|
71
|
+
@summaries = {}
|
|
72
|
+
@edges = {}
|
|
73
|
+
@superclasses = {}
|
|
74
|
+
@includes = {}
|
|
75
|
+
# ADR-103 WD10 / #387 — the class-body facts the framework-edge strategies read, harvested only
|
|
76
|
+
# when a loaded plugin declared one. A run with no `effect_edges:` never allocates them.
|
|
77
|
+
@harvest = plugin_facts.edges? ? {} : nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def scan(root)
|
|
81
|
+
walk(root, [], false)
|
|
82
|
+
synthesize_framework_units
|
|
83
|
+
FileCollection.new(
|
|
84
|
+
path: @path, summaries: @summaries, edges: @edges,
|
|
85
|
+
superclasses: @superclasses, includes: @includes
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def walk(node, prefix, singleton)
|
|
92
|
+
return unless node.is_a?(Prism::Node)
|
|
93
|
+
|
|
94
|
+
case node
|
|
95
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
96
|
+
return walk_namespace(node, prefix)
|
|
97
|
+
when Prism::SingletonClassNode
|
|
98
|
+
return walk(node.body, prefix, true) if node.body
|
|
99
|
+
when Prism::DefNode
|
|
100
|
+
return enter_def(node, prefix, singleton)
|
|
101
|
+
when Prism::CallNode
|
|
102
|
+
harvest_class_body_macro(node, prefix)
|
|
103
|
+
return record_declaration(node, prefix) if declaration?(node)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
node.rigor_each_child { |child| walk(child, prefix, singleton) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def walk_namespace(node, prefix)
|
|
110
|
+
name = Source::ConstantPath.qualified_name(node.constant_path)
|
|
111
|
+
return node.rigor_each_child { |child| walk(child, prefix, false) } if name.nil?
|
|
112
|
+
|
|
113
|
+
nested = prefix + [name]
|
|
114
|
+
record_superclass(nested.join("::"), node, prefix) if node.is_a?(Prism::ClassNode)
|
|
115
|
+
walk(node.body, nested, false) if node.body
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def enter_def(node, prefix, singleton)
|
|
119
|
+
instance_method = !singleton && node.receiver.nil?
|
|
120
|
+
harvest_for(prefix)[:defs] << node.name.to_s if @harvest && instance_method && !prefix.empty?
|
|
121
|
+
add_unit(
|
|
122
|
+
class_name_for(prefix), node.name.to_s, singleton || !node.receiver.nil?,
|
|
123
|
+
node.body, node.parameters
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# ADR-103 WD10 — a receiver-less call in a class body, recorded as `macro => [literal symbol
|
|
128
|
+
# arguments]`. That is all the framework-edge strategies need: `before_save :normalize` names a
|
|
129
|
+
# method on the same class, and a computed callback (`before_save -> { … }`, a method object) names
|
|
130
|
+
# none the strategy could resolve, so it contributes nothing rather than a guess. The block form is
|
|
131
|
+
# already contained in the class body, which v1 does not model as a unit at all.
|
|
132
|
+
def harvest_class_body_macro(node, prefix)
|
|
133
|
+
return if @harvest.nil? || prefix.empty? || !node.receiver.nil?
|
|
134
|
+
|
|
135
|
+
entry = harvest_for(prefix)
|
|
136
|
+
name = node.name.to_s
|
|
137
|
+
if FrameworkUnits::CALLBACK_MACROS.include?(name)
|
|
138
|
+
(entry[:macros][name] ||= []).concat(symbol_arguments(node))
|
|
139
|
+
elsif uniqueness_validator?(node, name)
|
|
140
|
+
entry[:uniqueness] = true
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# `validates :email, uniqueness: true` and `validates_uniqueness_of :email` — the validation whose
|
|
145
|
+
# implementation is a `SELECT`.
|
|
146
|
+
def uniqueness_validator?(node, name)
|
|
147
|
+
return true if name == FrameworkUnits::UNIQUENESS_MACRO
|
|
148
|
+
return false unless name == FrameworkUnits::VALIDATES_MACRO
|
|
149
|
+
|
|
150
|
+
node.arguments&.arguments&.any? do |argument|
|
|
151
|
+
argument.is_a?(Prism::KeywordHashNode) && argument.elements.any? do |element|
|
|
152
|
+
element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) &&
|
|
153
|
+
element.key.unescaped == FrameworkUnits::UNIQUENESS_OPTION
|
|
154
|
+
end
|
|
155
|
+
end || false
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def harvest_for(prefix)
|
|
159
|
+
@harvest[class_name_for(prefix)] ||= { defs: [], macros: {}, uniqueness: false }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Files the units the framework contributes for each class this file declares. Runs after the walk,
|
|
163
|
+
# because a callback macro may be written below the `def` it names and a mailer action may be
|
|
164
|
+
# declared anywhere in the body.
|
|
165
|
+
def synthesize_framework_units
|
|
166
|
+
return if @harvest.nil?
|
|
167
|
+
|
|
168
|
+
@harvest.each do |class_name, entry|
|
|
169
|
+
FrameworkUnits.synthesize(
|
|
170
|
+
class_name: class_name, instance_methods: entry[:defs], macros: entry[:macros],
|
|
171
|
+
uniqueness: entry[:uniqueness], plugin_facts: @plugin_facts
|
|
172
|
+
).each { |key, summary, edges| merge_unit(key, summary, edges) }
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Scans one unit and files its summary, then recurses into the units its body declared. Fail-soft
|
|
177
|
+
# per unit (ADR-103 WD13): a unit the scanner cannot finish is recorded as non-exhaustive with
|
|
178
|
+
# `collector-error` and its siblings are unaffected.
|
|
179
|
+
def add_unit(class_name, method_name, singleton, body, parameters)
|
|
180
|
+
key = "#{class_name}#{singleton ? '.' : '#'}#{method_name}"
|
|
181
|
+
names = parameter_names(parameters)
|
|
182
|
+
scan = UnitScan.new(
|
|
183
|
+
singleton: singleton, parameters: names,
|
|
184
|
+
block_parameter: block_parameter_name(parameters),
|
|
185
|
+
owned_locals: LocalOwnership.owned(body, names), calls: @calls,
|
|
186
|
+
attribution: @attribution, envelopes: @envelopes, plugin_facts: @plugin_facts,
|
|
187
|
+
owner_class: class_name
|
|
188
|
+
)
|
|
189
|
+
summary, edges = scan.run(body)
|
|
190
|
+
merge_unit(key, summary, edges)
|
|
191
|
+
scan.nested.each do |name, nested_singleton, nested_body, nested_parameters|
|
|
192
|
+
add_unit(class_name, name, singleton || nested_singleton, nested_body, nested_parameters)
|
|
193
|
+
end
|
|
194
|
+
rescue StandardError
|
|
195
|
+
merge_unit(key, Summary.tainted("collector-error", method_name), [])
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# A receiver-less call in a class / module body that declares units or ancestry. Class bodies are
|
|
199
|
+
# not themselves effect units in v1 (their statements run at load time), so nothing else in one
|
|
200
|
+
# contributes labels — but `include` and the accessor macros decide what the *methods* are.
|
|
201
|
+
def declaration?(node)
|
|
202
|
+
node.receiver.nil? && DECLARATION_MACROS.include?(node.name)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def record_declaration(node, prefix)
|
|
206
|
+
class_name = class_name_for(prefix)
|
|
207
|
+
case node.name
|
|
208
|
+
when :include, :prepend then record_includes(class_name, node, prefix)
|
|
209
|
+
when :define_method then declare_define_method(class_name, node)
|
|
210
|
+
else synthesize_accessors(class_name, node)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def declare_define_method(class_name, node)
|
|
215
|
+
unit = UnitScan.define_method_unit(node)
|
|
216
|
+
return if unit.nil?
|
|
217
|
+
|
|
218
|
+
name, singleton, body, parameters = unit
|
|
219
|
+
add_unit(class_name, name, singleton, body, parameters)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def synthesize_accessors(class_name, node)
|
|
223
|
+
symbol_arguments(node).each do |name|
|
|
224
|
+
merge_unit("#{class_name}##{name}", Summary.empty, []) unless node.name == :attr_writer
|
|
225
|
+
next if node.name == :attr_reader
|
|
226
|
+
|
|
227
|
+
merge_unit("#{class_name}##{name}=", WRITER_SUMMARY, [])
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def record_includes(class_name, node, prefix)
|
|
232
|
+
names = constant_arguments(node).flat_map { |name| lexical_candidates(name, prefix) }
|
|
233
|
+
(@includes[class_name] ||= []).concat(names) unless names.empty?
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def record_superclass(full_name, node, prefix)
|
|
237
|
+
superclass = node.superclass && Source::ConstantPath.qualified_name(node.superclass)
|
|
238
|
+
@superclasses[full_name] = lexical_candidates(superclass, prefix) if superclass
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# An ancestry name is recorded AS WRITTEN — `class Loud < Base` inside `module Tracer` names
|
|
242
|
+
# `Base`, not `Tracer::Base` — and a single file cannot say which constant that resolves to. So the
|
|
243
|
+
# scanner records the candidates Ruby's own lexical lookup would try, most-qualified first, and the
|
|
244
|
+
# propagator picks the one the merged project actually defines. Same shape as `ScopeIndexer`'s
|
|
245
|
+
# as-written superclass table, resolved at the same point: when the whole project is in view.
|
|
246
|
+
def lexical_candidates(name, prefix)
|
|
247
|
+
return [name] if prefix.empty? || name.start_with?("#{prefix.join('::')}::")
|
|
248
|
+
|
|
249
|
+
prefix.length.downto(1).map { |depth| "#{prefix.first(depth).join('::')}::#{name}" } + [name]
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def merge_unit(key, summary, edges)
|
|
253
|
+
@summaries[key] = @summaries.key?(key) ? @summaries[key].join(summary) : summary
|
|
254
|
+
(@edges[key] ||= []).concat(edges) unless edges.empty?
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def class_name_for(prefix)
|
|
258
|
+
prefix.empty? ? TOP_LEVEL_KEY : prefix.join("::")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def symbol_arguments(node)
|
|
262
|
+
node.arguments&.arguments&.filter_map { |argument| argument.unescaped if argument.is_a?(Prism::SymbolNode) } ||
|
|
263
|
+
[]
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def constant_arguments(node)
|
|
267
|
+
node.arguments&.arguments&.filter_map { |argument| Source::ConstantPath.qualified_name(argument) } || []
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def parameter_names(parameters)
|
|
271
|
+
return Set.new unless parameters.is_a?(Prism::ParametersNode)
|
|
272
|
+
|
|
273
|
+
names = Set.new
|
|
274
|
+
[parameters.requireds, parameters.optionals, parameters.posts, parameters.keywords].each do |group|
|
|
275
|
+
group.each { |parameter| names << parameter.name.to_s if parameter.respond_to?(:name) && parameter.name }
|
|
276
|
+
end
|
|
277
|
+
[parameters.rest, parameters.keyword_rest].each do |parameter|
|
|
278
|
+
names << parameter.name.to_s if parameter.respond_to?(:name) && parameter&.name
|
|
279
|
+
end
|
|
280
|
+
names
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def block_parameter_name(parameters)
|
|
284
|
+
return nil unless parameters.is_a?(Prism::ParametersNode)
|
|
285
|
+
|
|
286
|
+
parameters.block&.name&.to_s
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Effects
|
|
5
|
+
# Everything an effect envelope may be written in, as `[buffer name, RBS source]` pairs
|
|
6
|
+
# (ADR-103 WD5 / WD6; #383 / #384).
|
|
7
|
+
#
|
|
8
|
+
# Two strata, and the exclusion is the point: the project's own `signature_paths:` tree (or the
|
|
9
|
+
# `sig/` default `Environment.for_project` falls back to) plus the loader's **virtual** entries —
|
|
10
|
+
# which is how rbs-inline's `# @rbs %a{…}` comments and a plugin's `source_rbs` synthesis arrive.
|
|
11
|
+
# Gem-shipped and bundled RBS is deliberately absent: WD6 makes project-authored envelopes the
|
|
12
|
+
# checked stratum, so a `%a{pure}` in rbs core cannot bound a project method that shares its key.
|
|
13
|
+
#
|
|
14
|
+
# Shared by the two passes that read declarations — the envelope check and the
|
|
15
|
+
# annotations-unchecked residual — so the residual can never disagree with the check about what
|
|
16
|
+
# counts as "the project's own signatures".
|
|
17
|
+
module SignatureSources
|
|
18
|
+
# Where `Environment.for_project` looks when no `signature_paths:` is configured. Spelled again
|
|
19
|
+
# here rather than required, because the constant is private to {Rigor::Environment} and a walk
|
|
20
|
+
# over the project's own files has no business reaching into its builder.
|
|
21
|
+
DEFAULT_ROOTS = ["sig"].freeze
|
|
22
|
+
|
|
23
|
+
# A cheap text pre-filter for "does this source carry an effect annotation at all". It matches
|
|
24
|
+
# the two spellings the envelope reader honours and nothing else, so a signature tree with no
|
|
25
|
+
# effect annotation is answered by one regex per file and never parsed. It is a ROUTING test,
|
|
26
|
+
# not the grammar — `RbsExtended.parse_effect_annotation` is still what decides meaning.
|
|
27
|
+
ANNOTATION_HINT = /%a\{\s*(?:pure\s*\}|rigor:v1:effect\b)/
|
|
28
|
+
|
|
29
|
+
# A `virtual:<plugin-id>:<source path>` buffer is rbs-inline's (or a plugin's) synthesized RBS for
|
|
30
|
+
# a Ruby file the author actually wrote in. Naming that file is what a reader can act on, so the
|
|
31
|
+
# synthetic prefix comes off before the name is made project-relative.
|
|
32
|
+
VIRTUAL_BUFFER_PATTERN = /\Avirtual:[^:]*:/
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# The path a reader can open, for a buffer name from {.collect}: the `.rbs` file as configured,
|
|
37
|
+
# or the `.rb` file behind a `virtual:` buffer, project-relative either way.
|
|
38
|
+
def source_path(name)
|
|
39
|
+
stripped = name.to_s.sub(VIRTUAL_BUFFER_PATTERN, "")
|
|
40
|
+
root = "#{Dir.pwd}#{File::SEPARATOR}"
|
|
41
|
+
stripped.start_with?(root) ? stripped[root.length..] : stripped
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param signature_paths [Array<String>, nil] the configured roots; empty / nil takes the default.
|
|
45
|
+
# @param virtual_rbs [Array<Array(String, String)>, nil] the loader's virtual entries.
|
|
46
|
+
# @return [Array<Array(String, String)>]
|
|
47
|
+
def collect(signature_paths:, virtual_rbs: nil)
|
|
48
|
+
files = roots(signature_paths).flat_map { |root| Dir.glob(File.join(root.to_s, "**", "*.rbs")).sort }
|
|
49
|
+
sources = files.filter_map do |path|
|
|
50
|
+
[path, File.read(path)]
|
|
51
|
+
rescue StandardError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
sources + Array(virtual_rbs).map { |name, content| [name.to_s, content.to_s] }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def roots(signature_paths)
|
|
58
|
+
signature_paths.nil? || signature_paths.empty? ? DEFAULT_ROOTS : signature_paths
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The first `[name, content, line]` carrying an effect annotation, or nil. Line numbers are
|
|
62
|
+
# 1-based and count into `content`, which for a `virtual:` buffer is the SYNTHESIZED text — the
|
|
63
|
+
# caller re-anchors it against the Ruby file when that matters.
|
|
64
|
+
def first_annotated(sources)
|
|
65
|
+
sources.each do |name, content|
|
|
66
|
+
content.each_line.with_index(1) do |line, number|
|
|
67
|
+
return [name, content, number] if ANNOTATION_HINT.match?(line)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|