rigortype 0.3.4 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/data/effects/core.yml +18 -1
- data/data/effects/registry.yml +31 -3
- data/docs/manual/02-cli-reference.md +60 -13
- data/docs/manual/03-configuration.md +20 -3
- data/docs/manual/04-diagnostics.md +3 -2
- data/docs/manual/11-ci.md +37 -0
- data/docs/manual/12-caching.md +39 -0
- data/docs/manual/16-rbs-extended-annotations.md +15 -2
- data/docs/manual/19-effect-labels.md +671 -0
- data/docs/manual/README.md +5 -0
- data/docs/manual/ci-templates/README.md +9 -0
- data/lib/rigor/analysis/rule_catalog.rb +10 -3
- data/lib/rigor/analysis/run_cache_probe.rb +69 -1
- data/lib/rigor/analysis/runner/declaration_position.rb +8 -24
- data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +28 -28
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +1 -1
- data/lib/rigor/analysis/runner/pool_coordinator.rb +25 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
- data/lib/rigor/analysis/runner.rb +12 -3
- data/lib/rigor/analysis/worker_session.rb +3 -1
- data/lib/rigor/cli/effects_command.rb +123 -9
- data/lib/rigor/cli/effects_diff_renderer.rb +5 -1
- data/lib/rigor/cli/effects_renderer.rb +41 -3
- data/lib/rigor/cli/effects_report.rb +116 -6
- data/lib/rigor/cli/effects_snapshot_command.rb +23 -4
- data/lib/rigor/cli.rb +12 -1
- data/lib/rigor/configuration.rb +37 -25
- data/lib/rigor/configuration_error.rb +20 -0
- data/lib/rigor/effects/collector.rb +38 -1
- data/lib/rigor/effects/entry_points.rb +47 -1
- data/lib/rigor/effects/file_collection.rb +18 -4
- data/lib/rigor/effects/framework_units.rb +68 -13
- data/lib/rigor/effects/inline_anchor.rb +134 -0
- data/lib/rigor/effects/plugin_facts.rb +62 -13
- data/lib/rigor/effects/propagator.rb +79 -19
- data/lib/rigor/effects/registry.rb +10 -3
- data/lib/rigor/effects/scanner.rb +21 -9
- data/lib/rigor/effects/signature_sources.rb +16 -0
- data/lib/rigor/effects/snapshot.rb +21 -5
- data/lib/rigor/effects/taint_cause.rb +1 -0
- data/lib/rigor/effects/unit_scan.rb +87 -12
- data/lib/rigor/plugin/base.rb +4 -0
- data/lib/rigor/plugin/box.rb +18 -2
- data/lib/rigor/plugin/effect_ancestry.rb +80 -0
- data/lib/rigor/plugin/manifest.rb +34 -10
- data/lib/rigor/plugin/registry.rb +10 -3
- data/lib/rigor/rbs_extended.rb +22 -2
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +17 -2
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +13 -1
- metadata +6 -1
|
@@ -53,23 +53,49 @@ module Rigor
|
|
|
53
53
|
return EffectTable.empty if collection.summaries.empty?
|
|
54
54
|
|
|
55
55
|
summaries = collection.summaries
|
|
56
|
-
edges = resolve_edges(collection)
|
|
57
56
|
state = seed(summaries, discharge)
|
|
57
|
+
edges = resolve_edges(collection, state)
|
|
58
58
|
iterate(state, edges)
|
|
59
59
|
EffectTable.new(build_entries(summaries, edges, state))
|
|
60
60
|
rescue StandardError
|
|
61
61
|
EffectTable.empty
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
-
# `{caller_key => [callee_key]}`, sorted and de-duplicated.
|
|
65
|
-
|
|
64
|
+
# `{caller_key => [callee_key]}`, sorted and de-duplicated. Seeds the `unresolved-super` taint in
|
|
65
|
+
# the same pass, because whether a `super` resolved is exactly what this resolution answers.
|
|
66
|
+
def resolve_edges(collection, state)
|
|
66
67
|
index = Index.new(collection)
|
|
67
68
|
collection.edges.each_with_object({}) do |(caller_key, list), out|
|
|
68
|
-
targets = list.flat_map
|
|
69
|
+
targets = list.flat_map do |edge|
|
|
70
|
+
resolved = index.targets_for(edge)
|
|
71
|
+
taint_unresolved_super(state, caller_key, edge) if edge.super_call && resolved.empty?
|
|
72
|
+
resolved
|
|
73
|
+
end.uniq.sort
|
|
69
74
|
out[caller_key] = targets.freeze unless targets.empty?
|
|
70
75
|
end
|
|
71
76
|
end
|
|
72
77
|
|
|
78
|
+
# A `super` the project's own ancestry does not answer — the implementation is in a gem, in Ruby's
|
|
79
|
+
# core, or in a module prepended at run time — is the case the taint exists for (#446). Silence
|
|
80
|
+
# would be the one thing the effect model must never say: that the list is complete when a call in
|
|
81
|
+
# the body was not read. An unresolved ORDINARY edge is dropped instead, because most such calls
|
|
82
|
+
# are inherited ones the catalogue simply has no row for and the site's own taint was already
|
|
83
|
+
# decided from the typer's verdict; a `super` has no site verdict to fall back on, and it always
|
|
84
|
+
# dispatches to something.
|
|
85
|
+
#
|
|
86
|
+
# Decided here rather than in the collector because only the merged ancestry can say whether the
|
|
87
|
+
# parent resolves, and written into the SEED rather than into the direct summary so the fixpoint
|
|
88
|
+
# carries it to this method's callers exactly as it carries any other cause. A snapshot's
|
|
89
|
+
# `methods:` table records direct summaries and so does not show it; `reach:`, the report and the
|
|
90
|
+
# judgment all read the closure and do.
|
|
91
|
+
def taint_unresolved_super(state, caller_key, edge)
|
|
92
|
+
entry = state[caller_key]
|
|
93
|
+
return if entry.nil?
|
|
94
|
+
|
|
95
|
+
entry[:exhaustive] = false
|
|
96
|
+
entry[:causes] << ["unresolved-super", edge.selector].freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
73
99
|
# Causes are carried as a Set through the fixpoint and flattened back to a sorted Array in
|
|
74
100
|
# {build_entries}. A Set is what {absorb} needs: unioning one along an edge must cost the source's
|
|
75
101
|
# size and allocate NOTHING when it adds nothing, and the array-concat-and-uniq it replaces
|
|
@@ -170,8 +196,8 @@ module Rigor
|
|
|
170
196
|
end
|
|
171
197
|
end
|
|
172
198
|
|
|
173
|
-
private_class_method :resolve_edges, :seed, :iterate, :reverse_edges,
|
|
174
|
-
:build_entries
|
|
199
|
+
private_class_method :resolve_edges, :taint_unresolved_super, :seed, :iterate, :reverse_edges,
|
|
200
|
+
:absorb, :join_lane, :build_entries
|
|
175
201
|
|
|
176
202
|
# The class graph a run's collections describe, and the edge resolution over it. Built once per
|
|
177
203
|
# propagation; every lookup is a Hash read.
|
|
@@ -189,25 +215,43 @@ module Rigor
|
|
|
189
215
|
# Every project method key `edge` may reach: the definition its ancestry resolves to, plus every
|
|
190
216
|
# override of the same selector in a project subclass of the receiver's class.
|
|
191
217
|
#
|
|
192
|
-
# Memoised on `(receiver class, kind, selector)` — the answer depends on nothing else, and
|
|
193
|
-
# such
|
|
218
|
+
# Memoised on `(receiver class, kind, selector, super?)` — the answer depends on nothing else, and
|
|
219
|
+
# one such tuple is asked for once per call site in the project. `ApplicationRecord#save` alone is
|
|
194
220
|
# thousands of sites on a Rails app, each of which used to re-walk the whole subclass forest.
|
|
195
221
|
def targets_for(edge)
|
|
196
|
-
@targets[[edge.receiver_class, edge.kind, edge.selector]] ||= begin
|
|
222
|
+
@targets[[edge.receiver_class, edge.kind, edge.selector, edge.super_call]] ||= begin
|
|
197
223
|
separator = edge.kind == :singleton ? "." : "#"
|
|
198
|
-
|
|
199
|
-
owner = resolve_owner(edge.receiver_class, separator, edge.selector)
|
|
200
|
-
targets << owner if owner
|
|
201
|
-
descendant_closure(edge.receiver_class).each do |subclass|
|
|
202
|
-
key = "#{subclass}#{separator}#{edge.selector}"
|
|
203
|
-
targets << key if @summaries.key?(key)
|
|
204
|
-
end
|
|
205
|
-
targets.freeze
|
|
224
|
+
edge.super_call ? super_targets(edge, separator) : call_targets(edge, separator)
|
|
206
225
|
end
|
|
207
226
|
end
|
|
208
227
|
|
|
209
228
|
private
|
|
210
229
|
|
|
230
|
+
def call_targets(edge, separator)
|
|
231
|
+
targets = []
|
|
232
|
+
owner = resolve_owner(edge.receiver_class, separator, edge.selector)
|
|
233
|
+
targets << owner if owner
|
|
234
|
+
descendant_closure(edge.receiver_class).each do |subclass|
|
|
235
|
+
key = "#{subclass}#{separator}#{edge.selector}"
|
|
236
|
+
targets << key if @summaries.key?(key)
|
|
237
|
+
end
|
|
238
|
+
targets.freeze
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# A `super` reaches **one** definition, and the closed-world override join every other edge gets
|
|
242
|
+
# is deliberately absent from it (#446). `super` in `C#m` dispatches into the ancestry above `C`
|
|
243
|
+
# in the receiver's chain, and a subclass of `C` is never in it however the receiver was
|
|
244
|
+
# constructed — so joining `D#m` would put a proven label on `C#m` that no execution of `C#m` can
|
|
245
|
+
# produce. Ruby's lack of `final` is the argument for the join at an ordinary call site and says
|
|
246
|
+
# nothing here.
|
|
247
|
+
def super_targets(edge, separator)
|
|
248
|
+
target = resolve_super(edge.receiver_class, separator, edge.selector)
|
|
249
|
+
target ? [target].freeze : NO_TARGETS
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
NO_TARGETS = [].freeze
|
|
253
|
+
private_constant :NO_TARGETS
|
|
254
|
+
|
|
211
255
|
# The transitive subclass closure, memoised per class. A deep hierarchy's root is asked for it
|
|
212
256
|
# once, not once per selector reaching it.
|
|
213
257
|
def descendant_closure(class_name)
|
|
@@ -220,8 +264,24 @@ module Rigor
|
|
|
220
264
|
# enqueued and the most-qualified one comes first, so the right constant wins the race and a
|
|
221
265
|
# spelling that names nothing simply matches no key.
|
|
222
266
|
def resolve_owner(class_name, separator, selector)
|
|
223
|
-
|
|
224
|
-
|
|
267
|
+
walk_ancestors([class_name], Set.new, separator, selector)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Where `super` from `class_name#selector` lands: the same ancestor walk, started one step up —
|
|
271
|
+
# the modules the class includes, then its superclass chain — with the class itself already in
|
|
272
|
+
# `seen`, because a method never `super`s into itself.
|
|
273
|
+
#
|
|
274
|
+
# The includes are instance-side only. `include M` puts `M#m` between the class and its
|
|
275
|
+
# superclass, which is exactly where `super` looks, while `M.m` is a singleton method `include`
|
|
276
|
+
# never contributes; a singleton `super` that really does reach a module went through `extend` or
|
|
277
|
+
# a `class << self` include, neither of which is collected, so it resolves to nothing and taints.
|
|
278
|
+
def resolve_super(class_name, separator, selector)
|
|
279
|
+
queue = separator == "#" ? @includes.fetch(class_name, []).dup : []
|
|
280
|
+
queue.concat(@superclasses.fetch(class_name, []))
|
|
281
|
+
walk_ancestors(queue, Set.new([class_name]), separator, selector)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def walk_ancestors(queue, seen, separator, selector)
|
|
225
285
|
until queue.empty?
|
|
226
286
|
current = queue.shift
|
|
227
287
|
next if current.nil? || !seen.add?(current)
|
|
@@ -74,14 +74,16 @@ module Rigor
|
|
|
74
74
|
new(
|
|
75
75
|
vocabulary_version: raw.fetch("vocabulary", 0),
|
|
76
76
|
labels: raw.fetch("labels", nil) || [],
|
|
77
|
-
retired: raw.fetch("retired", nil) || {}
|
|
77
|
+
retired: raw.fetch("retired", nil) || {},
|
|
78
|
+
descriptions: raw.fetch("descriptions", nil) || {}
|
|
78
79
|
)
|
|
79
80
|
end
|
|
80
81
|
|
|
81
82
|
attr_reader :vocabulary_version
|
|
82
83
|
|
|
83
|
-
def initialize(vocabulary_version:, labels:, retired: {})
|
|
84
|
+
def initialize(vocabulary_version:, labels:, retired: {}, descriptions: {})
|
|
84
85
|
@vocabulary_version = vocabulary_version
|
|
86
|
+
@descriptions = descriptions.to_h { |root, text| [root.to_s, text.to_s.gsub(/\s+/, " ").strip] }.freeze
|
|
85
87
|
@labels = labels.map(&:to_s).uniq.sort.freeze
|
|
86
88
|
@known = build_known(@labels)
|
|
87
89
|
@roots = @known.select { |label| Label.parent(label).nil? }.sort.freeze
|
|
@@ -96,6 +98,10 @@ module Rigor
|
|
|
96
98
|
# The roots of the vocabulary — the outermost segments {#with} treats as already owned.
|
|
97
99
|
attr_reader :roots
|
|
98
100
|
|
|
101
|
+
# One line per root, from the data file's `descriptions:` — what `rigor effects --list-labels`
|
|
102
|
+
# prints beside a root (#429). A root nothing describes (a plugin's, a project's) has no entry.
|
|
103
|
+
attr_reader :descriptions
|
|
104
|
+
|
|
99
105
|
# Whether the vocabulary recognises `label`: an exact row, or an ancestor of one. A declared
|
|
100
106
|
# `io` is recognised because `io.net` exists, so a bound may name an interior node the data
|
|
101
107
|
# file never spells out on its own line.
|
|
@@ -142,7 +148,8 @@ module Rigor
|
|
|
142
148
|
|
|
143
149
|
check_ownership(label, owner)
|
|
144
150
|
end
|
|
145
|
-
self.class.new(vocabulary_version: @vocabulary_version, labels: @labels + added, retired: @retired
|
|
151
|
+
self.class.new(vocabulary_version: @vocabulary_version, labels: @labels + added, retired: @retired,
|
|
152
|
+
descriptions: @descriptions)
|
|
146
153
|
end
|
|
147
154
|
|
|
148
155
|
private
|
|
@@ -116,12 +116,20 @@ module Rigor
|
|
|
116
116
|
end
|
|
117
117
|
|
|
118
118
|
def enter_def(node, prefix, singleton)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
119
|
+
own_singleton = singleton || !node.receiver.nil?
|
|
120
|
+
scan = add_unit(class_name_for(prefix), node.name.to_s, own_singleton, node.body, node.parameters)
|
|
121
|
+
harvest_def(prefix, node.name.to_s, own_singleton, scan) if @harvest && !prefix.empty?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# What the framework strategies need to know about a `def` the class body spelled out itself: that it
|
|
125
|
+
# exists, and whether it reaches `super`. `:defs` stays instance-only, because a mailer action is an
|
|
126
|
+
# instance method; `:units` is keyed by the suffix a synthetic unit key carries, so both sides of the
|
|
127
|
+
# `#` / `.` split are answerable (#440). A body the collector could not finish reads as delegating,
|
|
128
|
+
# which keeps the framework's claim — the fail-soft direction for an upper bound.
|
|
129
|
+
def harvest_def(prefix, name, singleton, scan)
|
|
130
|
+
entry = harvest_for(prefix)
|
|
131
|
+
entry[:defs] << name unless singleton
|
|
132
|
+
entry[:units]["#{singleton ? '.' : '#'}#{name}"] = scan.nil? || scan.delegates_upward?
|
|
125
133
|
end
|
|
126
134
|
|
|
127
135
|
# ADR-103 WD10 — a receiver-less call in a class body, recorded as `macro => [literal symbol
|
|
@@ -156,7 +164,7 @@ module Rigor
|
|
|
156
164
|
end
|
|
157
165
|
|
|
158
166
|
def harvest_for(prefix)
|
|
159
|
-
@harvest[class_name_for(prefix)] ||= { defs: [], macros: {}, uniqueness: false }
|
|
167
|
+
@harvest[class_name_for(prefix)] ||= { defs: [], units: {}, macros: {}, uniqueness: false }
|
|
160
168
|
end
|
|
161
169
|
|
|
162
170
|
# Files the units the framework contributes for each class this file declares. Runs after the walk,
|
|
@@ -168,7 +176,7 @@ module Rigor
|
|
|
168
176
|
@harvest.each do |class_name, entry|
|
|
169
177
|
FrameworkUnits.synthesize(
|
|
170
178
|
class_name: class_name, instance_methods: entry[:defs], macros: entry[:macros],
|
|
171
|
-
uniqueness: entry[:uniqueness], plugin_facts: @plugin_facts
|
|
179
|
+
uniqueness: entry[:uniqueness], plugin_facts: @plugin_facts, own_units: entry[:units]
|
|
172
180
|
).each { |key, summary, edges| merge_unit(key, summary, edges) }
|
|
173
181
|
end
|
|
174
182
|
end
|
|
@@ -176,6 +184,8 @@ module Rigor
|
|
|
176
184
|
# Scans one unit and files its summary, then recurses into the units its body declared. Fail-soft
|
|
177
185
|
# per unit (ADR-103 WD13): a unit the scanner cannot finish is recorded as non-exhaustive with
|
|
178
186
|
# `collector-error` and its siblings are unaffected.
|
|
187
|
+
#
|
|
188
|
+
# @return [UnitScan, nil] the finished scan, or nil when the unit failed soft
|
|
179
189
|
def add_unit(class_name, method_name, singleton, body, parameters)
|
|
180
190
|
key = "#{class_name}#{singleton ? '.' : '#'}#{method_name}"
|
|
181
191
|
names = parameter_names(parameters)
|
|
@@ -184,15 +194,17 @@ module Rigor
|
|
|
184
194
|
block_parameter: block_parameter_name(parameters),
|
|
185
195
|
owned_locals: LocalOwnership.owned(body, names), calls: @calls,
|
|
186
196
|
attribution: @attribution, envelopes: @envelopes, plugin_facts: @plugin_facts,
|
|
187
|
-
owner_class: class_name
|
|
197
|
+
owner_class: class_name, method_name: method_name
|
|
188
198
|
)
|
|
189
199
|
summary, edges = scan.run(body)
|
|
190
200
|
merge_unit(key, summary, edges)
|
|
191
201
|
scan.nested.each do |name, nested_singleton, nested_body, nested_parameters|
|
|
192
202
|
add_unit(class_name, name, singleton || nested_singleton, nested_body, nested_parameters)
|
|
193
203
|
end
|
|
204
|
+
scan
|
|
194
205
|
rescue StandardError
|
|
195
206
|
merge_unit(key, Summary.tainted("collector-error", method_name), [])
|
|
207
|
+
nil
|
|
196
208
|
end
|
|
197
209
|
|
|
198
210
|
# A receiver-less call in a class / module body that declares units or ancestry. Class bodies are
|
|
@@ -58,6 +58,22 @@ module Rigor
|
|
|
58
58
|
signature_paths.nil? || signature_paths.empty? ? DEFAULT_ROOTS : signature_paths
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
# The FIRST annotated virtual entry, as a `collect`-shaped one-element array (or an empty one).
|
|
62
|
+
#
|
|
63
|
+
# #441 — the analysis paths use this to hand the annotations-unchecked residual the inline stratum
|
|
64
|
+
# of a run that resolved an environment but never stored it. Reduced to one entry on purpose: the
|
|
65
|
+
# residual reports one annotation per run, so the carrier a run holds past the environment's own
|
|
66
|
+
# lifetime is a single synthesized buffer rather than the project's whole virtual tree.
|
|
67
|
+
#
|
|
68
|
+
# @param virtual_rbs [Array<Array(String, String)>, nil]
|
|
69
|
+
# @return [Array<Array(String, String)>] zero or one pair.
|
|
70
|
+
def annotated_carrier(virtual_rbs)
|
|
71
|
+
Array(virtual_rbs).each do |name, content|
|
|
72
|
+
return [[name.to_s, content.to_s]] if ANNOTATION_HINT.match?(content)
|
|
73
|
+
end
|
|
74
|
+
[].freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
61
77
|
# The first `[name, content, line]` carrying an effect annotation, or nil. Line numbers are
|
|
62
78
|
# 1-based and count into `content`, which for a `virtual:` buffer is the SYNTHESIZED text — the
|
|
63
79
|
# caller re-anchors it against the Ruby file when that matters.
|
|
@@ -164,11 +164,7 @@ module Rigor
|
|
|
164
164
|
entries.flat_map do |entry|
|
|
165
165
|
next [entry] if EntryPoints.glob?(entry)
|
|
166
166
|
|
|
167
|
-
EntryPoints.
|
|
168
|
-
raise(EntryPoints::Error,
|
|
169
|
-
"effects.snapshot.reach names no registered entry-point preset: #{entry.inspect} " \
|
|
170
|
-
"(registered: #{EntryPoints.names.inspect}; a preset is named by the plugin that " \
|
|
171
|
-
"models the framework, so listing that plugin is what registers it)")
|
|
167
|
+
EntryPoints.resolve!(entry)
|
|
172
168
|
end.uniq.sort.freeze
|
|
173
169
|
end
|
|
174
170
|
|
|
@@ -335,6 +331,25 @@ module Rigor
|
|
|
335
331
|
# Renders the file. Hand-rolled rather than `YAML.dump`ed so the leading comment, the key order and
|
|
336
332
|
# the flow sequences are ours: two runs over one tree must be byte-identical, and a snapshot written
|
|
337
333
|
# by a pooled run must equal the sequential one bit for bit.
|
|
334
|
+
# The hint goes in the artefact, not only in the run's output (#436): `reach:` is the half of the
|
|
335
|
+
# snapshot that answers what an entry point *causes*, and a project that never configured one
|
|
336
|
+
# commits the less interesting half with no way to know it. The run also names the presets its own
|
|
337
|
+
# plugin set registered, which this file cannot — a committed artefact must not carry a list that
|
|
338
|
+
# goes stale when the plugin list changes.
|
|
339
|
+
EMPTY_REACH_NOTE = [
|
|
340
|
+
"",
|
|
341
|
+
"# `reach:` is empty. It records the TRANSITIVE footprint at your entry points — what a",
|
|
342
|
+
"# controller action or a job causes, rather than what its own body does — and it is the half of",
|
|
343
|
+
"# this file most worth reviewing. Fill it in by naming entry points in `.rigor.yml`:",
|
|
344
|
+
"#",
|
|
345
|
+
"# effects:",
|
|
346
|
+
"# snapshot:",
|
|
347
|
+
"# reach: [rails] # a preset a plugin registered, or a project-relative file glob",
|
|
348
|
+
"#",
|
|
349
|
+
"# `rigor effects update` prints the presets this project's plugins actually register."
|
|
350
|
+
].freeze
|
|
351
|
+
private_constant :EMPTY_REACH_NOTE
|
|
352
|
+
|
|
338
353
|
def to_yaml
|
|
339
354
|
lines = [HEADER]
|
|
340
355
|
lines << "schema: #{@header.fetch('schema')}"
|
|
@@ -342,6 +357,7 @@ module Rigor
|
|
|
342
357
|
lines << "vocabulary: #{@header.fetch('vocabulary')}"
|
|
343
358
|
lines << "config_digest: #{scalar(@header.fetch('config_digest'))}"
|
|
344
359
|
render_table(lines, "methods", @methods)
|
|
360
|
+
lines.concat(EMPTY_REACH_NOTE) if @reach.empty?
|
|
345
361
|
render_table(lines, "reach", @reach)
|
|
346
362
|
"#{lines.join("\n")}\n"
|
|
347
363
|
end
|
|
@@ -110,9 +110,12 @@ module Rigor
|
|
|
110
110
|
# @param plugin_facts [PluginFacts] the loaded plugins' `effect_attributions:` (#387)
|
|
111
111
|
# @param owner_class [String, nil] the class this unit is defined on — the carrier an
|
|
112
112
|
# implicit-self call's envelope is looked up under, since the syntax spells `Kernel#name`
|
|
113
|
+
# @param method_name [String, nil] this unit's own selector — what a `super` in its body names as
|
|
114
|
+
# the target the propagator resolves above `owner_class` (#446). With no name to state, a `super`
|
|
115
|
+
# taints instead.
|
|
113
116
|
def initialize(singleton:, parameters:, block_parameter:, owned_locals:, calls:, # rubocop:disable Metrics/ParameterLists
|
|
114
117
|
attribution: Attribution.empty, envelopes: EnvelopeIndex.empty,
|
|
115
|
-
plugin_facts: PluginFacts.empty, owner_class: nil)
|
|
118
|
+
plugin_facts: PluginFacts.empty, owner_class: nil, method_name: nil)
|
|
116
119
|
@singleton = singleton
|
|
117
120
|
@block_parameter = block_parameter
|
|
118
121
|
@calls = calls
|
|
@@ -120,6 +123,7 @@ module Rigor
|
|
|
120
123
|
@envelopes = envelopes
|
|
121
124
|
@plugin_facts = plugin_facts
|
|
122
125
|
@owner_class = owner_class
|
|
126
|
+
@method_name = method_name
|
|
123
127
|
@mutation = MutationClassifier.new(
|
|
124
128
|
singleton: singleton, parameters: parameters, owned_locals: owned_locals
|
|
125
129
|
)
|
|
@@ -128,12 +132,24 @@ module Rigor
|
|
|
128
132
|
@causes = []
|
|
129
133
|
@edges = []
|
|
130
134
|
@nested = []
|
|
135
|
+
@delegates_upward = false
|
|
131
136
|
end
|
|
132
137
|
|
|
133
138
|
# Units discovered inside this one — a nested `def`, or a `define_method` with a literal name whose
|
|
134
139
|
# block becomes that method's body. Each is `[name, singleton, body_node, parameters_node]`.
|
|
135
140
|
attr_reader :nested
|
|
136
141
|
|
|
142
|
+
# Whether this body reaches `super` — an override that delegates upward still runs whatever the
|
|
143
|
+
# superclass does. Nested `def`s are unit boundaries, so a `super` counted here is this unit's.
|
|
144
|
+
#
|
|
145
|
+
# A separate reading of the same node from the edge {#visit_super} records: what this bit answers is
|
|
146
|
+
# whether a framework's claim about the selector survives the class having written a body for it
|
|
147
|
+
# ({FrameworkUnits.replaced?}, #440), which is a question about the *class*, not about what the
|
|
148
|
+
# parent implementation does.
|
|
149
|
+
def delegates_upward?
|
|
150
|
+
@delegates_upward
|
|
151
|
+
end
|
|
152
|
+
|
|
137
153
|
# Walks `body` and returns `[Summary, edges]`.
|
|
138
154
|
def run(body)
|
|
139
155
|
walk(body)
|
|
@@ -214,9 +230,31 @@ module Rigor
|
|
|
214
230
|
when Prism::IndexOperatorWriteNode, Prism::IndexOrWriteNode, Prism::IndexAndWriteNode,
|
|
215
231
|
Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode
|
|
216
232
|
classify_mutation(node.receiver)
|
|
233
|
+
when Prism::SuperNode, Prism::ForwardingSuperNode
|
|
234
|
+
visit_super
|
|
217
235
|
end
|
|
218
236
|
end
|
|
219
237
|
|
|
238
|
+
# `super` in every shape — bare, `super()`, `super(args)`, and each of those inside a block or a
|
|
239
|
+
# rescue, since the walk descends into both (#446).
|
|
240
|
+
#
|
|
241
|
+
# It is a dispatch and must contribute like one. What the scan can settle from the syntax is the
|
|
242
|
+
# *identity* of the target — this unit's own class and selector — and nothing more: which definition
|
|
243
|
+
# sits above the class is a question about the whole project, so it goes out as an edge and the
|
|
244
|
+
# propagator resolves it against the merged ancestry, tainting when nothing there answers.
|
|
245
|
+
#
|
|
246
|
+
# A unit with no class or no name has no target to state. That is not a shape the scanner produces,
|
|
247
|
+
# and the taint is what a "cannot say" must read as rather than a silently dropped call.
|
|
248
|
+
def visit_super
|
|
249
|
+
@delegates_upward = true
|
|
250
|
+
return taint("unresolved-super", @method_name) if @owner_class.nil? || @method_name.nil?
|
|
251
|
+
|
|
252
|
+
@edges << FileCollection::Edge.new(
|
|
253
|
+
receiver_class: @owner_class, kind: @singleton ? :singleton : :instance,
|
|
254
|
+
selector: @method_name, self_call: true, super_call: true
|
|
255
|
+
)
|
|
256
|
+
end
|
|
257
|
+
|
|
220
258
|
def visit_call(node)
|
|
221
259
|
record = @calls[node]
|
|
222
260
|
attribute(node, record)
|
|
@@ -285,14 +323,30 @@ module Rigor
|
|
|
285
323
|
|
|
286
324
|
# The class whose call produced this receiver, for an `on_result:` row: the constant in
|
|
287
325
|
# `UserMailer.welcome(u).deliver_now`, the receiver class the typer had otherwise. Nil unless the
|
|
288
|
-
# receiver is itself a call
|
|
326
|
+
# receiver is itself a call — a bare local variable says nothing about what made it.
|
|
327
|
+
#
|
|
328
|
+
# Two shapes beyond the constant, because between them they are how mail is actually sent (#456):
|
|
329
|
+
#
|
|
330
|
+
# - **an implicit-self producing call** — `issue_add(user, issue).deliver_later`, which is what a
|
|
331
|
+
# mailer's own `def self.deliver_issue_add` wrapper looks like in Redmine, 97 call sites of it.
|
|
332
|
+
# The producer is the unit's own class, which is exactly what an unqualified name resolves
|
|
333
|
+
# against;
|
|
334
|
+
# - **a builder one link further out** — `AdminMailer.with(recipient: a).new_trends(…).deliver_later!`,
|
|
335
|
+
# ActionMailer's parameterized form and the spelling Mastodon uses. `with` returns a lazy
|
|
336
|
+
# `Parameterized::Mailer` no project declares a type for, so the constant is two calls away rather
|
|
337
|
+
# than one, and stopping at the first link found nothing.
|
|
338
|
+
#
|
|
339
|
+
# The walk is the same heuristic the `on_result:` row already is — "the class that produced this" —
|
|
340
|
+
# applied transitively rather than once. A row still has to match the producer's ancestry AND the
|
|
341
|
+
# selector, so a chain that ends somewhere unrelated contributes nothing rather than a wrong label.
|
|
289
342
|
def producer_class(receiver)
|
|
290
343
|
return nil unless receiver.is_a?(Prism::CallNode)
|
|
291
344
|
|
|
292
345
|
inner = receiver.receiver
|
|
293
|
-
return
|
|
346
|
+
return @owner_class if inner.nil?
|
|
294
347
|
|
|
295
|
-
Source::ConstantPath.qualified_name(inner) ||
|
|
348
|
+
Source::ConstantPath.qualified_name(inner) || producer_class(inner) ||
|
|
349
|
+
@calls[receiver]&.receiver_class
|
|
296
350
|
end
|
|
297
351
|
|
|
298
352
|
# The receiver expression as the syntax spells it — `"Rails.cache"`, `"self.flash.now"` — or nil when
|
|
@@ -379,12 +433,12 @@ module Rigor
|
|
|
379
433
|
# receiver mutation; `Time#localtime` is both). A **posture** states nothing about this selector,
|
|
380
434
|
# so the uncatalogued path's own mutation rule applies to it unchanged.
|
|
381
435
|
def claimed_by_catalogue?(node, record)
|
|
382
|
-
owner, singleton, implicit = catalog_target(node, record)
|
|
436
|
+
owner, singleton, implicit, named = catalog_target(node, record)
|
|
383
437
|
return false if owner.nil?
|
|
384
438
|
|
|
385
439
|
entry = Catalog.default.lookup(
|
|
386
440
|
owner, node.name.to_s, singleton: singleton, call_node: node,
|
|
387
|
-
posture: posture_allowed?(node, record, implicit)
|
|
441
|
+
posture: posture_allowed?(node, record, implicit, named)
|
|
388
442
|
)
|
|
389
443
|
return false if entry.nil?
|
|
390
444
|
|
|
@@ -426,8 +480,26 @@ module Rigor
|
|
|
426
480
|
# it would be a proven label with nothing proving it (the `dynamic-receiver` taint is the right
|
|
427
481
|
# answer, and the uncatalogued path records it);
|
|
428
482
|
# - `send` / `call`, whose own taints are the more specific reading ({DEFERRED_SELECTORS}).
|
|
429
|
-
|
|
430
|
-
|
|
483
|
+
#
|
|
484
|
+
# **Unless the syntax named the class** (#463). The `Dynamic` exclusion exists because a projected
|
|
485
|
+
# receiver class is a guess; a written constant path is not one, and {#catalog_target} already
|
|
486
|
+
# trusts it for the row lookup. Refusing the posture there made the catalogue's reach a function of
|
|
487
|
+
# whether the bundled rbs happens to ship a signature for the constant: `Net::HTTP.get` proves
|
|
488
|
+
# `io.net.http` and `Net::IMAP.new` proved nothing, because rbs ships `net-http` and not
|
|
489
|
+
# `net-imap`. That is why the `Net::FTP` row has never once fired, and why Redmine's IMAP and POP3
|
|
490
|
+
# pollers proved no network at all.
|
|
491
|
+
#
|
|
492
|
+
# The site is then **claimed**, and so discharges, like every other catalogue answer. That is not a
|
|
493
|
+
# softening of the `dynamic-receiver` rule but its premise failing: the taint says the class the
|
|
494
|
+
# typer projected is a guess, and on a constant path there is no projection — the receiver of
|
|
495
|
+
# `Net::IMAP.new` is that constant, exactly. What the posture asserts on top is the hand-audited
|
|
496
|
+
# claim the catalogue is made of, the same one `Socket#connect` rests on when the typer did name
|
|
497
|
+
# the receiver. `implicit` and {DEFERRED_SELECTORS} keep their exclusions, which are about what the
|
|
498
|
+
# syntax means rather than about what the typer knew.
|
|
499
|
+
def posture_allowed?(node, record, implicit, named)
|
|
500
|
+
return false if implicit || DEFERRED_SELECTORS.include?(node.name)
|
|
501
|
+
|
|
502
|
+
named || !record&.dynamic
|
|
431
503
|
end
|
|
432
504
|
|
|
433
505
|
def visit_uncatalogued(node, record, bound = nil)
|
|
@@ -550,18 +622,21 @@ module Rigor
|
|
|
550
622
|
# The class the catalogue would look this call up under, as `[owner, singleton, implicit_self]`.
|
|
551
623
|
# Spelled from the syntax where the syntax settles it and from the typer's receiver otherwise;
|
|
552
624
|
# `[nil, …]` when neither does.
|
|
625
|
+
# `[owner, singleton, implicit, named]`. `named` says the owner came from a **constant path the
|
|
626
|
+
# author wrote**, which is what licenses the posture on a receiver the typer could not resolve
|
|
627
|
+
# ({#posture_allowed?}).
|
|
553
628
|
def catalog_target(node, record)
|
|
554
629
|
receiver = node.receiver
|
|
555
|
-
return ["Kernel", false, true] if receiver.nil? || receiver.is_a?(Prism::SelfNode)
|
|
630
|
+
return ["Kernel", false, true, false] if receiver.nil? || receiver.is_a?(Prism::SelfNode)
|
|
556
631
|
|
|
557
632
|
constant = Source::ConstantPath.qualified_name(receiver)
|
|
558
|
-
return [constant, !Catalog.default.object_constant?(constant), false] if constant
|
|
633
|
+
return [constant, !Catalog.default.object_constant?(constant), false, true] if constant
|
|
559
634
|
return NO_TARGET if record.nil? || record.receiver_class.nil?
|
|
560
635
|
|
|
561
|
-
[record.receiver_class, record.kind == :singleton, false]
|
|
636
|
+
[record.receiver_class, record.kind == :singleton, false, false]
|
|
562
637
|
end
|
|
563
638
|
|
|
564
|
-
NO_TARGET = [nil, false, false].freeze
|
|
639
|
+
NO_TARGET = [nil, false, false, false].freeze
|
|
565
640
|
private_constant :NO_TARGET
|
|
566
641
|
|
|
567
642
|
def positional_arity(node)
|
data/lib/rigor/plugin/base.rb
CHANGED
|
@@ -734,6 +734,10 @@ module Rigor
|
|
|
734
734
|
manifest.effect_entry_points
|
|
735
735
|
end
|
|
736
736
|
|
|
737
|
+
def effect_ancestry
|
|
738
|
+
manifest.effect_ancestry
|
|
739
|
+
end
|
|
740
|
+
|
|
737
741
|
# ADR-7 § "Slice 6-A/6-B" — per-plugin {IoBoundary}. Memoised so the boundary's accumulated `FileEntry`
|
|
738
742
|
# rows persist across producer invocations within the same plugin instance and feed cache invalidation
|
|
739
743
|
# via `cache_for`.
|
data/lib/rigor/plugin/box.rb
CHANGED
|
@@ -32,14 +32,30 @@ module Rigor
|
|
|
32
32
|
# Requires `feature` into the shared box exactly once. Returns true when the feature is available in the
|
|
33
33
|
# box, false when it could not be loaded (the caller then declines — never falls back to loading into the
|
|
34
34
|
# main space, which would defeat the boundary).
|
|
35
|
+
#
|
|
36
|
+
# The rescue is `::`-qualified and covers `::ScriptError`, matching {Isolation::Process.run_worker_loop}:
|
|
37
|
+
# a failed box require raises `::LoadError` — a ScriptError, not a StandardError — and a plain
|
|
38
|
+
# `rescue StandardError` lets it escape and abort the whole run instead of the clean decline.
|
|
35
39
|
def require_feature(feature)
|
|
36
40
|
@required ||= {}
|
|
37
41
|
return @required[feature] if @required.key?(feature)
|
|
38
42
|
|
|
39
43
|
shared.require(feature)
|
|
40
44
|
@required[feature] = true
|
|
41
|
-
rescue StandardError
|
|
42
|
-
@required[feature] =
|
|
45
|
+
rescue ::StandardError, ::ScriptError
|
|
46
|
+
@required[feature] = eval_require(feature)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The gem-resolving fallback: `Ruby::Box#require` resolves against the raw `$LOAD_PATH` only (it
|
|
50
|
+
# calls the C-level require directly), so a target library installed as a gem is reachable only
|
|
51
|
+
# through the box's own RubyGems — the `Kernel#require` *inside* the box. `feature` is a fixed,
|
|
52
|
+
# plugin-declared name rendered via `String#inspect` (a safe Ruby literal), so the eval carries no
|
|
53
|
+
# free input. Returns false when the box cannot load the feature either way (the caller declines).
|
|
54
|
+
def eval_require(feature)
|
|
55
|
+
shared.eval("require #{feature.inspect}") # require "active_support/inflector"
|
|
56
|
+
true
|
|
57
|
+
rescue ::StandardError, ::ScriptError
|
|
58
|
+
false
|
|
43
59
|
end
|
|
44
60
|
|
|
45
61
|
# Evaluates `code` inside the shared box and returns the result across the box boundary. The caller MUST
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "effect_attribution"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
# One entry of a plugin's `effect_ancestry:` — an ancestry edge the plugin's own gem introduces and
|
|
8
|
+
# the project's source never writes down (ADR-103 WD17; [#465](https://github.com/rigortype/rigor/issues/465)).
|
|
9
|
+
#
|
|
10
|
+
# `Effects::PluginFacts` walks the project's own `class … <` and `include` lines and nothing else,
|
|
11
|
+
# deliberately: reading the RBS ancestor chain instead would make a row's reach a function of whether
|
|
12
|
+
# anyone happened to run `rbs prototype`. The cost is that a chain which leaves project source never
|
|
13
|
+
# comes back. `class UserMailer < Devise::Mailer` is the measured case — `Devise::Mailer <
|
|
14
|
+
# ActionMailer::Base` is a line in the devise gem, so `rigor-actionmailer`'s rows stop one step short
|
|
15
|
+
# of every mailer a Devise application writes, and the five `Auth::*Controller < Devise::*Controller`
|
|
16
|
+
# subclasses beside it are entry points.
|
|
17
|
+
#
|
|
18
|
+
# A plugin that models a gem knows that gem's own inheritance. Declaring it here keeps the rule that
|
|
19
|
+
# Rigor reads *declarations* rather than RBS: the declaration simply comes from the plugin instead of
|
|
20
|
+
# from project source.
|
|
21
|
+
#
|
|
22
|
+
# ## What a claim may say
|
|
23
|
+
#
|
|
24
|
+
# `parent:` need only be a **true ancestor**, not the immediate superclass. The sole use of the
|
|
25
|
+
# ancestry is to make a row reachable, and no plugin row is ever keyed on a project class, so
|
|
26
|
+
# skipping intermediate links loses nothing — while insisting on the immediate parent would force a
|
|
27
|
+
# claim that is sometimes false: `Devise::SessionsController`'s real parent is `DeviseController`,
|
|
28
|
+
# whose own parent is `Devise.parent_controller` and therefore configurable per project. A claim that
|
|
29
|
+
# skips links MUST say so in its `why:`.
|
|
30
|
+
#
|
|
31
|
+
# ## Who may make one
|
|
32
|
+
#
|
|
33
|
+
# Bundled plugins only ({Rigor::Plugin::FirstParty}), enforced in `PluginFacts`. An ancestry claim
|
|
34
|
+
# carries no labels of its own, so it looks harmless — but it makes *other* plugins' rows reachable,
|
|
35
|
+
# and a third-party plugin asserting `Foo < ActiveRecord::Base` would pull rigor-activerecord's
|
|
36
|
+
# first-party discharging rows onto `Foo`. The `effect_root:` demotion and the `discharge:` grant
|
|
37
|
+
# both answer their own version of that question the same way.
|
|
38
|
+
class EffectAncestry
|
|
39
|
+
attr_reader :child, :parent, :why
|
|
40
|
+
|
|
41
|
+
def initialize(child:, parent:, why:)
|
|
42
|
+
@child = validate_class_name!(child, "child")
|
|
43
|
+
@parent = validate_class_name!(parent, "parent")
|
|
44
|
+
@why = validate_why!(why)
|
|
45
|
+
freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_h
|
|
49
|
+
{ "child" => @child, "parent" => @parent }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def ==(other)
|
|
53
|
+
other.is_a?(EffectAncestry) && to_h == other.to_h
|
|
54
|
+
end
|
|
55
|
+
alias eql? ==
|
|
56
|
+
|
|
57
|
+
def hash
|
|
58
|
+
to_h.hash
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def validate_class_name!(value, role)
|
|
64
|
+
name = value.to_s
|
|
65
|
+
unless EffectAttribution::CLASS_NAME.match?(name)
|
|
66
|
+
raise ArgumentError, "effect ancestry #{role} must be a class name, got #{value.inspect}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
name.dup.freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate_why!(why)
|
|
73
|
+
value = why.to_s
|
|
74
|
+
raise ArgumentError, "effect ancestry #{@child} < #{@parent} needs a `why:` justification" if value.empty?
|
|
75
|
+
|
|
76
|
+
value.dup.freeze
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|