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
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../configuration_error"
|
|
4
|
+
|
|
3
5
|
module Rigor
|
|
4
6
|
module Effects
|
|
5
7
|
# The named entry-point presets `effects.snapshot.reach:` may adopt (ADR-103 WD14).
|
|
@@ -30,9 +32,19 @@ module Rigor
|
|
|
30
32
|
# than becoming an unfindable key.
|
|
31
33
|
NAME_PATTERN = /\A[a-z0-9][a-z0-9_-]*\z/
|
|
32
34
|
|
|
35
|
+
# A plugin's registration is wrong: a malformed preset name, or two plugins claiming one name with
|
|
36
|
+
# different globs. Nothing the user wrote, so it never reaches them as a configuration error —
|
|
37
|
+
# {PluginFacts} downgrades it to a warning.
|
|
33
38
|
class Error < StandardError
|
|
34
39
|
end
|
|
35
40
|
|
|
41
|
+
# The user's `effects.snapshot.reach:` names a preset no active plugin registered — the most common
|
|
42
|
+
# of these being chapter 03's `reach: [rails]` without `rigor-railties` in `plugins:` (#433). A
|
|
43
|
+
# {Rigor::ConfigurationError}, not an {Error}, because the offending text is in `.rigor.yml`: it is
|
|
44
|
+
# the CLI's to render as a `rigor:` line, not a crash.
|
|
45
|
+
class UnknownPreset < Rigor::ConfigurationError
|
|
46
|
+
end
|
|
47
|
+
|
|
36
48
|
@presets = {}
|
|
37
49
|
|
|
38
50
|
class << self
|
|
@@ -73,7 +85,8 @@ module Rigor
|
|
|
73
85
|
Array(presets).each { |preset| register(preset.name, preset.globs) }
|
|
74
86
|
end
|
|
75
87
|
|
|
76
|
-
# Every registered preset name, sorted. Empty
|
|
88
|
+
# Every registered preset name, sorted. Empty until a plugin declaring `effect_entry_points:`
|
|
89
|
+
# loads, which is what makes {availability} worth printing.
|
|
77
90
|
def names
|
|
78
91
|
@presets.keys.sort.freeze
|
|
79
92
|
end
|
|
@@ -83,6 +96,39 @@ module Rigor
|
|
|
83
96
|
@presets[name.to_s]
|
|
84
97
|
end
|
|
85
98
|
|
|
99
|
+
# The globs `name` stands for, or the configuration error that says what this project could have
|
|
100
|
+
# written instead.
|
|
101
|
+
#
|
|
102
|
+
# The existence check lives here and runs from the snapshot build rather than from
|
|
103
|
+
# `Configuration` — presets are registered by plugins, and the plugins load FROM the configuration
|
|
104
|
+
# being validated, so at load time the registry is still empty. See {name?} for the shape check
|
|
105
|
+
# that does run at load.
|
|
106
|
+
def resolve!(name)
|
|
107
|
+
globs_for(name) || raise(
|
|
108
|
+
UnknownPreset,
|
|
109
|
+
"effects.snapshot.reach names no registered entry-point preset: #{name.to_s.inspect} " \
|
|
110
|
+
"(#{availability})"
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# What this project may write in `effects.snapshot.reach:`, as one parenthetical clause.
|
|
115
|
+
#
|
|
116
|
+
# Single-homed here because the answer is a property of the loaded plugin set rather than of any
|
|
117
|
+
# one message: the unregistered-preset error (#433) and the note `rigor effects update` prints
|
|
118
|
+
# when `reach:` is empty (#436) have to give the same answer, and neither can hard-code a list
|
|
119
|
+
# that moves with `plugins:`.
|
|
120
|
+
#
|
|
121
|
+
# @return [String]
|
|
122
|
+
def availability
|
|
123
|
+
if @presets.empty?
|
|
124
|
+
return "no plugin in this project registers an entry-point preset — a preset is named by the " \
|
|
125
|
+
"plugin that models the framework, so listing that plugin under `plugins:` is what " \
|
|
126
|
+
"registers one"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
"presets registered in this project: #{names.join(', ')}"
|
|
130
|
+
end
|
|
131
|
+
|
|
86
132
|
# Drops every registration. For specs only — production code registers and never unregisters.
|
|
87
133
|
def reset!
|
|
88
134
|
@presets = {}
|
|
@@ -30,7 +30,16 @@ module Rigor
|
|
|
30
30
|
# receiver, or a construct that is not a call). `kind` is `:instance` for a `Nominal` receiver and
|
|
31
31
|
# `:singleton` for a `Singleton` one. `self_call` marks an implicit-self call, which is the only shape
|
|
32
32
|
# whose failure to resolve is an `unresolved-self-call` taint rather than silence.
|
|
33
|
-
|
|
33
|
+
#
|
|
34
|
+
# `super_call` marks the edge a `super` contributes (#446). It carries the ENCLOSING unit's class and
|
|
35
|
+
# selector rather than a receiver's, and the propagator resolves it against the ancestry *above* that
|
|
36
|
+
# class with no closed-world override join — a different question from every other edge, which is why
|
|
37
|
+
# it is a field rather than a convention over the other three.
|
|
38
|
+
Edge = Data.define(:receiver_class, :kind, :selector, :self_call, :super_call) do
|
|
39
|
+
# Defaulted because every producer but the `super` one records an ordinary call, and an ordinary
|
|
40
|
+
# call is not a `super`.
|
|
41
|
+
def initialize(super_call: false, **) = super
|
|
42
|
+
end
|
|
34
43
|
|
|
35
44
|
NO_TABLE = {}.freeze
|
|
36
45
|
private_constant :NO_TABLE
|
|
@@ -144,17 +153,22 @@ module Rigor
|
|
|
144
153
|
end
|
|
145
154
|
|
|
146
155
|
# Edge lists are sorted so a marshalled worker collection and a sequential one are `==` and the
|
|
147
|
-
# report they feed is byte-identical. The key is TOTAL over the de-duplicated list — `self_call`
|
|
148
|
-
# in it because two edges can otherwise agree on every other field
|
|
156
|
+
# report they feed is byte-identical. The key is TOTAL over the de-duplicated list — `self_call` and
|
|
157
|
+
# `super_call` are in it because two edges can otherwise agree on every other field (`def emit; super;
|
|
158
|
+
# emit; end` records both), and `sort_by` is not stable.
|
|
149
159
|
def freeze_edges(table)
|
|
150
160
|
return NO_TABLE if table.empty?
|
|
151
161
|
|
|
152
162
|
table.transform_values do |list|
|
|
153
163
|
sorted = list.uniq
|
|
154
|
-
sorted.sort_by! { |edge|
|
|
164
|
+
sorted.sort_by! { |edge| edge_order(edge) }
|
|
155
165
|
sorted.freeze
|
|
156
166
|
end.freeze
|
|
157
167
|
end
|
|
168
|
+
|
|
169
|
+
def edge_order(edge)
|
|
170
|
+
[edge.receiver_class.to_s, edge.kind.to_s, edge.selector, edge.self_call ? 1 : 0, edge.super_call ? 1 : 0]
|
|
171
|
+
end
|
|
158
172
|
end
|
|
159
173
|
end
|
|
160
174
|
end
|
|
@@ -82,12 +82,15 @@ module Rigor
|
|
|
82
82
|
# arguments, as the scanner harvested them
|
|
83
83
|
# @param uniqueness [Boolean] whether the class body declares a uniqueness validator
|
|
84
84
|
# @param plugin_facts [PluginFacts]
|
|
85
|
-
|
|
85
|
+
# @param own_units [Hash{String=>Boolean}] the units the class body itself defines, keyed by the
|
|
86
|
+
# suffix a synthetic key carries (`"#save"`, `".create"`), each mapped to whether that body
|
|
87
|
+
# reaches `super`. Read by {.framework_row} and by nothing else.
|
|
88
|
+
def synthesize(class_name:, instance_methods:, macros:, uniqueness:, plugin_facts:, own_units: {})
|
|
86
89
|
units = []
|
|
87
90
|
plugin_facts.edges_for(:activerecord_callbacks).each do |edge|
|
|
88
91
|
next unless plugin_facts.descends_from?(class_name, edge.receiver)
|
|
89
92
|
|
|
90
|
-
units.concat(active_record_units(class_name, macros, uniqueness))
|
|
93
|
+
units.concat(active_record_units(class_name, macros, uniqueness, plugin_facts, own_units))
|
|
91
94
|
end
|
|
92
95
|
plugin_facts.edges_for(:perform_now).each do |edge|
|
|
93
96
|
next unless plugin_facts.descends_from?(class_name, edge.receiver)
|
|
@@ -110,17 +113,18 @@ module Rigor
|
|
|
110
113
|
# `save` and friends, edged to the callbacks the class body declared. A trigger with no callbacks and
|
|
111
114
|
# no uniqueness validator is NOT synthesised: an empty unit would put `User#save` in the snapshot for
|
|
112
115
|
# every model in the project and say nothing.
|
|
113
|
-
def active_record_units(class_name, macros, uniqueness)
|
|
116
|
+
def active_record_units(class_name, macros, uniqueness, plugin_facts, own_units)
|
|
114
117
|
validation = callbacks(macros, VALIDATION_MACROS)
|
|
115
118
|
save = validation + callbacks(macros, SAVE_MACROS)
|
|
116
119
|
destroy = callbacks(macros, DESTROY_MACROS)
|
|
117
120
|
read = uniqueness ? uniqueness_summary(class_name) : nil
|
|
121
|
+
context = { plugin_facts: plugin_facts, own_units: own_units }
|
|
118
122
|
|
|
119
123
|
units = []
|
|
120
|
-
units.concat(triggers(class_name, SAVE_TRIGGERS, save, read, singleton: false))
|
|
121
|
-
units.concat(triggers(class_name, SINGLETON_SAVE_TRIGGERS, save, read, singleton: true))
|
|
122
|
-
units.concat(triggers(class_name, VALIDATION_TRIGGERS, validation, read, singleton: false))
|
|
123
|
-
units.concat(triggers(class_name, DESTROY_TRIGGERS, destroy, nil, singleton: false))
|
|
124
|
+
units.concat(triggers(class_name, SAVE_TRIGGERS, save, read, singleton: false, **context))
|
|
125
|
+
units.concat(triggers(class_name, SINGLETON_SAVE_TRIGGERS, save, read, singleton: true, **context))
|
|
126
|
+
units.concat(triggers(class_name, VALIDATION_TRIGGERS, validation, read, singleton: false, **context))
|
|
127
|
+
units.concat(triggers(class_name, DESTROY_TRIGGERS, destroy, nil, singleton: false, **context))
|
|
124
128
|
units
|
|
125
129
|
end
|
|
126
130
|
|
|
@@ -132,11 +136,61 @@ module Rigor
|
|
|
132
136
|
end
|
|
133
137
|
end
|
|
134
138
|
|
|
135
|
-
|
|
139
|
+
# A synthesised trigger exists only when the class body earned it — see {.active_record_units} — but
|
|
140
|
+
# once it exists it stands for the whole of `save`, so it carries the framework's own claim about the
|
|
141
|
+
# selector as well as the callbacks (#440).
|
|
142
|
+
def triggers(class_name, selectors, targets, read, singleton:, plugin_facts:, own_units:)
|
|
136
143
|
return [] if targets.empty? && read.nil?
|
|
137
144
|
|
|
138
145
|
edges = targets.map { |target| edge_to(class_name, target) }
|
|
139
|
-
selectors.map
|
|
146
|
+
selectors.map do |selector|
|
|
147
|
+
row = framework_row(class_name, selector, singleton, plugin_facts, own_units)
|
|
148
|
+
unit("#{class_name}#{singleton ? '.' : '#'}#{selector}", edges,
|
|
149
|
+
declared_bundles(read, row), causes(row))
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# What the loaded plugins say `class_name`'s `selector` itself does — `ActiveRecord::Base#save` is
|
|
154
|
+
# `io.db.write` — read off the very table a call site reads (#440).
|
|
155
|
+
#
|
|
156
|
+
# Without it the synthetic unit carried the validator's `SELECT` and nothing else, so a model with a
|
|
157
|
+
# `before_save` or a uniqueness validator reported `AuthSource#save: ≤ io.db.read`: the write was
|
|
158
|
+
# attributed at every *call site* and never on the row that names the method, which is the row a
|
|
159
|
+
# reviewer reads. A `narrow:` row is skipped, because narrowing reads an argument at a call site and
|
|
160
|
+
# there is no call site here.
|
|
161
|
+
def framework_row(class_name, selector, singleton, plugin_facts, own_units)
|
|
162
|
+
return nil if replaced?(own_units, selector, singleton)
|
|
163
|
+
|
|
164
|
+
row = plugin_facts.class_row(class_name, singleton, selector)
|
|
165
|
+
return nil if row.nil? || row.narrow || row.labels.empty?
|
|
166
|
+
|
|
167
|
+
row
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Whether the class spelled the selector out itself and never reaches `super`. Such a body REPLACED
|
|
171
|
+
# the framework's implementation, so the framework's claim no longer describes what runs: a
|
|
172
|
+
# `def save = false` that persists nothing must keep reporting nothing. A body that does reach
|
|
173
|
+
# `super` keeps the claim, which is the case the exemption exists to not break.
|
|
174
|
+
def replaced?(own_units, selector, singleton)
|
|
175
|
+
key = "#{singleton ? '.' : '#'}#{selector}"
|
|
176
|
+
own_units.key?(key) && !own_units[key]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def declared_bundles(read, row)
|
|
180
|
+
bundles = read ? read.dup : {}
|
|
181
|
+
bundles[Origin.plugin(row.key)] = row.labels if row
|
|
182
|
+
bundles
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Mirrors {UnitScan#attribute_plugin}: a row may discharge and still taint, and a row from a plugin
|
|
186
|
+
# the engine does not bundle is a claim that leaves the unit non-exhaustive.
|
|
187
|
+
def causes(row)
|
|
188
|
+
return [] if row.nil?
|
|
189
|
+
|
|
190
|
+
list = []
|
|
191
|
+
list << [row.taint, row.key] if row.taint
|
|
192
|
+
list << ["plugin-attribution", row.key] unless row.discharge?
|
|
193
|
+
list
|
|
140
194
|
end
|
|
141
195
|
|
|
142
196
|
def callbacks(macros, names)
|
|
@@ -151,8 +205,9 @@ module Rigor
|
|
|
151
205
|
{ Origin.plugin("#{class_name}:uniqueness-validator") => IO_DB_READ }
|
|
152
206
|
end
|
|
153
207
|
|
|
154
|
-
def unit(key, edges, declared = nil)
|
|
155
|
-
|
|
208
|
+
def unit(key, edges, declared = nil, causes = [])
|
|
209
|
+
summary = Summary.new(declared_bundles: declared || {}, exhaustive: causes.empty?, causes: causes)
|
|
210
|
+
[key, summary, edges]
|
|
156
211
|
end
|
|
157
212
|
|
|
158
213
|
def edge_to(class_name, selector)
|
|
@@ -160,8 +215,8 @@ module Rigor
|
|
|
160
215
|
self_call: false)
|
|
161
216
|
end
|
|
162
217
|
|
|
163
|
-
private_class_method :active_record_units, :mailer_units, :triggers, :
|
|
164
|
-
:unit, :edge_to
|
|
218
|
+
private_class_method :active_record_units, :mailer_units, :triggers, :framework_row, :replaced?,
|
|
219
|
+
:declared_bundles, :causes, :callbacks, :uniqueness_summary, :unit, :edge_to
|
|
165
220
|
end
|
|
166
221
|
end
|
|
167
222
|
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Effects
|
|
5
|
+
# Maps a position read out of a **synthesized** RBS buffer back onto the Ruby file the author
|
|
6
|
+
# actually wrote in ([#432](https://github.com/rigortype/rigor/issues/432)).
|
|
7
|
+
#
|
|
8
|
+
# An rbs-inline annotation never reaches the envelope reader as the line the author typed. The
|
|
9
|
+
# plugin hands `RBS::Inline::Writer`'s output to the loader as a `virtual:rbs-inline:<path>.rb`
|
|
10
|
+
# buffer, and that output is a *fresh document*: bodies are gone, the members it emits are the
|
|
11
|
+
# signatures rather than the `def`s, and every annotation is re-emitted above its member on a line
|
|
12
|
+
# of the writer's choosing. The buffer name still names the `.rb`, so a location rendered straight
|
|
13
|
+
# off `RBS::Location` reads `app/models/change.rb:5` — the right file, and a line number that
|
|
14
|
+
# belongs to a document nobody has. On a file with a licence header it lands inside the copyright
|
|
15
|
+
# notice and is not even visibly wrong.
|
|
16
|
+
#
|
|
17
|
+
# ## Why the answer is the annotation's own text, matched by ordinal
|
|
18
|
+
#
|
|
19
|
+
# There is no line table to consult: upstream's writer emits text and keeps no mapping, and the
|
|
20
|
+
# synthesized buffer is what the RBS parser saw. What both documents *do* share is the author's own
|
|
21
|
+
# spelling — `%a{pure}` reaches the buffer verbatim — and their order, because the writer emits
|
|
22
|
+
# members in source order. So the k-th `%a{pure}` of the buffer is the k-th `%a{pure}` of the `.rb`,
|
|
23
|
+
# and matching on the ordinal is what keeps a file with several identically-bounded methods from
|
|
24
|
+
# pointing all of them at the first one. A plain "find the text" search cannot tell them apart, and
|
|
25
|
+
# a fixed offset would encode one fixture's licence header as if it were a rule.
|
|
26
|
+
#
|
|
27
|
+
# Two filters make the two lists comparable:
|
|
28
|
+
#
|
|
29
|
+
# - in the buffer, only a line whose own text *is* the annotation counts. The writer also echoes the
|
|
30
|
+
# author's comment block above each member, so `# @rbs %a{pure}` appears there too and would
|
|
31
|
+
# double every count;
|
|
32
|
+
# - in the `.rb`, only a comment line counts, so a `%a{…}` inside a string literal or a heredoc is
|
|
33
|
+
# never mistaken for a declaration.
|
|
34
|
+
#
|
|
35
|
+
# Every step degrades to the buffer line it was given: a `.rb` that cannot be read, a spelling with
|
|
36
|
+
# no match, a writer that some day stops preserving order. A position one line off is a smaller
|
|
37
|
+
# harm than a raised exception on a run that had a real finding to report.
|
|
38
|
+
class InlineAnchor
|
|
39
|
+
RUBY_EXTENSION = ".rb"
|
|
40
|
+
private_constant :RUBY_EXTENSION
|
|
41
|
+
|
|
42
|
+
# An RBS annotation as the parser sees it: the line's own text, not a comment mentioning one.
|
|
43
|
+
ANNOTATION_PREFIX = "%a{"
|
|
44
|
+
private_constant :ANNOTATION_PREFIX
|
|
45
|
+
|
|
46
|
+
COMMENT_PREFIX = "#"
|
|
47
|
+
private_constant :COMMENT_PREFIX
|
|
48
|
+
|
|
49
|
+
SPELLING_PATTERN = /%a\{[^}]*\}/
|
|
50
|
+
private_constant :SPELLING_PATTERN
|
|
51
|
+
|
|
52
|
+
# @param path [String] the buffer's readable path, as {SignatureSources.source_path} renders it.
|
|
53
|
+
# @param buffer [String] the synthesized RBS the position was read out of.
|
|
54
|
+
# @return [InlineAnchor, nil] nil when `path` is not a Ruby file — a real `.rbs` needs no mapping,
|
|
55
|
+
# and its own line numbers are already the ones a reader can open.
|
|
56
|
+
def self.for(path:, buffer:)
|
|
57
|
+
return nil unless path.to_s.end_with?(RUBY_EXTENSION)
|
|
58
|
+
|
|
59
|
+
new(path: path.to_s, buffer: buffer.to_s)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# One-shot form, for a caller that holds a `[path, buffer, line]` triple and no anchor.
|
|
63
|
+
#
|
|
64
|
+
# @return [Integer] the `.rb` line, or `buffer_line` unchanged when there is nothing to map.
|
|
65
|
+
def self.ruby_line(path:, buffer:, buffer_line:, spelling: nil)
|
|
66
|
+
anchor = self.for(path: path, buffer: buffer)
|
|
67
|
+
return buffer_line if anchor.nil?
|
|
68
|
+
|
|
69
|
+
anchor.line_for(buffer_line, spelling: spelling)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def initialize(path:, buffer:)
|
|
73
|
+
@path = path
|
|
74
|
+
@buffer = buffer
|
|
75
|
+
@ruby_lines = nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @param buffer_line [Integer] 1-based, counting into the synthesized buffer.
|
|
79
|
+
# @param spelling [String, nil] the annotation's own text (`"%a{pure}"`); read off the buffer line
|
|
80
|
+
# when the caller does not already hold it.
|
|
81
|
+
# @return [Integer] the 1-based line of the same annotation in the Ruby file.
|
|
82
|
+
def line_for(buffer_line, spelling: nil)
|
|
83
|
+
spelling = normalize(spelling) || spelling_at(buffer_line)
|
|
84
|
+
return buffer_line if spelling.nil?
|
|
85
|
+
|
|
86
|
+
candidates = ruby_lines.filter_map { |number, text| number if text.include?(spelling) }
|
|
87
|
+
return buffer_line if candidates.empty?
|
|
88
|
+
|
|
89
|
+
candidates[ordinal_of(buffer_line, spelling)] || candidates.first
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# The spelling as it appears in both documents. Callers hold it as `"%a{#{annotation.string}}"`,
|
|
95
|
+
# which is already that form; anything else is read for its annotation text or dropped.
|
|
96
|
+
def normalize(spelling)
|
|
97
|
+
spelling.to_s[SPELLING_PATTERN]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def spelling_at(buffer_line)
|
|
101
|
+
@buffer.each_line.with_index(1) do |text, number|
|
|
102
|
+
return text[SPELLING_PATTERN] if number == buffer_line
|
|
103
|
+
end
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# How many annotations of the same spelling the buffer declares before `buffer_line`. Comment
|
|
108
|
+
# lines are skipped: the writer echoes the author's `# @rbs %a{…}` above the annotation it
|
|
109
|
+
# generates, and counting both would land every lookup one match too far down the file.
|
|
110
|
+
def ordinal_of(buffer_line, spelling)
|
|
111
|
+
seen = 0
|
|
112
|
+
@buffer.each_line.with_index(1) do |text, number|
|
|
113
|
+
break if number >= buffer_line
|
|
114
|
+
|
|
115
|
+
stripped = text.lstrip
|
|
116
|
+
seen += 1 if stripped.start_with?(ANNOTATION_PREFIX) && stripped.include?(spelling)
|
|
117
|
+
end
|
|
118
|
+
seen
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The Ruby file's comment lines, as `[line number, text]`. Read once, and only for a position that
|
|
122
|
+
# is already going to be rendered.
|
|
123
|
+
def ruby_lines
|
|
124
|
+
@ruby_lines ||= begin
|
|
125
|
+
File.foreach(@path).with_index(1).filter_map do |text, number|
|
|
126
|
+
[number, text] if text.lstrip.start_with?(COMMENT_PREFIX)
|
|
127
|
+
end
|
|
128
|
+
rescue StandardError
|
|
129
|
+
[].freeze
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -46,9 +46,16 @@ module Rigor
|
|
|
46
46
|
# models are declared only in RBS gets no plugin attribution and no taint, which is the fail-quiet
|
|
47
47
|
# direction.
|
|
48
48
|
class PluginFacts
|
|
49
|
-
# How
|
|
50
|
-
#
|
|
51
|
-
|
|
49
|
+
# How many ancestors the walk visits before giving up. It bounds *work*, not correctness: cycles are
|
|
50
|
+
# already handled by the visited set, and the walk is memoised per class.
|
|
51
|
+
#
|
|
52
|
+
# It was 24 while the walk climbed superclasses only, where a project deeper than that is
|
|
53
|
+
# pathological. Once included modules joined the walk (#456) the number stopped measuring depth and
|
|
54
|
+
# started measuring breadth, and Rails concerns make breadth ordinary — Mastodon's `Account` includes
|
|
55
|
+
# 21 modules, so `ActiveRecord::Base` fell off the end of a 24-entry chain and every `Account#save`
|
|
56
|
+
# silently lost its `io.db.write`. A cap that can be reached by ordinary code is a correctness bug
|
|
57
|
+
# wearing a budget's clothes.
|
|
58
|
+
ANCESTRY_CAP = 128
|
|
52
59
|
|
|
53
60
|
NO_ROWS = {}.freeze
|
|
54
61
|
private_constant :NO_ROWS
|
|
@@ -67,18 +74,21 @@ module Rigor
|
|
|
67
74
|
Edge = Data.define(:target, :receiver, :selector, :plugin_id)
|
|
68
75
|
|
|
69
76
|
def self.empty
|
|
70
|
-
@empty ||= new(contributions: [], superclasses: NO_ROWS)
|
|
77
|
+
@empty ||= new(contributions: [], superclasses: NO_ROWS, includes: NO_ROWS)
|
|
71
78
|
end
|
|
72
79
|
|
|
73
80
|
# @param plugin_registry [Rigor::Plugin::Registry, nil]
|
|
74
81
|
# @param superclasses [Hash{String=>String,Array<String>}] the project's as-written superclass table
|
|
75
82
|
# (`Scope::DiscoveryIndex#discovered_superclasses`). Empty is legal and simply means no row matches
|
|
76
83
|
# through inheritance.
|
|
77
|
-
|
|
84
|
+
# @param includes [Hash{String=>Array<String>}] the project's as-written include / prepend table
|
|
85
|
+
# (`Scope::DiscoveryIndex#discovered_includes`), for the frameworks whose contract is a module
|
|
86
|
+
# rather than a base class (#456).
|
|
87
|
+
def self.build(plugin_registry, superclasses: NO_ROWS, includes: NO_ROWS)
|
|
78
88
|
contributions = plugin_registry&.effect_contributions || []
|
|
79
89
|
return empty if contributions.empty?
|
|
80
90
|
|
|
81
|
-
new(contributions: contributions, superclasses: superclasses)
|
|
91
|
+
new(contributions: contributions, superclasses: superclasses, includes: includes)
|
|
82
92
|
rescue StandardError
|
|
83
93
|
empty
|
|
84
94
|
end
|
|
@@ -94,7 +104,7 @@ module Rigor
|
|
|
94
104
|
# Every `effect_entry_points:` preset across the loaded set, in registration order.
|
|
95
105
|
attr_reader :entry_points
|
|
96
106
|
|
|
97
|
-
def initialize(contributions:, superclasses:)
|
|
107
|
+
def initialize(contributions:, superclasses:, includes: NO_ROWS)
|
|
98
108
|
@warnings = []
|
|
99
109
|
@class_rows = {}
|
|
100
110
|
@path_rows = {}
|
|
@@ -103,8 +113,10 @@ module Rigor
|
|
|
103
113
|
@edges = []
|
|
104
114
|
@labels_by_owner = {}
|
|
105
115
|
@entry_points = []
|
|
116
|
+
@declared_ancestry = {}
|
|
106
117
|
contributions.each { |contribution| absorb(contribution) }
|
|
107
118
|
@superclasses = superclasses || NO_ROWS
|
|
119
|
+
@includes = includes || NO_ROWS
|
|
108
120
|
@ancestry = {}
|
|
109
121
|
@digest = compute_digest
|
|
110
122
|
finalize
|
|
@@ -220,6 +232,27 @@ module Rigor
|
|
|
220
232
|
plugin_id: contribution.id)
|
|
221
233
|
end
|
|
222
234
|
@entry_points.concat(contribution.entry_points)
|
|
235
|
+
contribution.ancestry.each { |entry| absorb_ancestry(contribution, entry) }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# ADR-103 WD17 (#465) — an ancestry edge the plugin's gem introduces, which the project's own
|
|
239
|
+
# source never writes.
|
|
240
|
+
#
|
|
241
|
+
# **Bundled plugins only**, and this is the one place it is enforced. A claim carries no labels of
|
|
242
|
+
# its own, which is what makes it look harmless: what it does is make *other* plugins' rows
|
|
243
|
+
# reachable, so a third-party plugin asserting `Foo < ActiveRecord::Base` would pull
|
|
244
|
+
# rigor-activerecord's first-party discharging rows onto `Foo`. The grant is the same fact
|
|
245
|
+
# {#discharge_granted?} reads — that the engine bundles this plugin — and the refusal is warned
|
|
246
|
+
# about rather than silent, because a claim that quietly did nothing would read to its author as
|
|
247
|
+
# the rows having vanished.
|
|
248
|
+
def absorb_ancestry(contribution, entry)
|
|
249
|
+
unless contribution.discharge_allowed
|
|
250
|
+
@warnings << "plugin #{contribution.id.inspect} is not bundled with the engine; its " \
|
|
251
|
+
"#{entry.child} < #{entry.parent} ancestry claim is ignored"
|
|
252
|
+
return
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
(@declared_ancestry[entry.child] ||= []) << entry.parent
|
|
223
256
|
end
|
|
224
257
|
|
|
225
258
|
# ADR-103 WD2 — a plugin that asked to own a framework root and is not one the engine bundles keeps
|
|
@@ -259,16 +292,31 @@ module Rigor
|
|
|
259
292
|
false
|
|
260
293
|
end
|
|
261
294
|
|
|
262
|
-
# `[class_name, …ancestors]`, memoised per class. Cycle-guarded and capped: the
|
|
263
|
-
# so a project can spell
|
|
295
|
+
# `[class_name, …ancestors]`, memoised per class. Cycle-guarded and capped: the tables are *as
|
|
296
|
+
# written*, so a project can spell an ancestry that loops.
|
|
297
|
+
#
|
|
298
|
+
# Included and prepended modules are walked beside the superclass, nearer first, because that is
|
|
299
|
+
# Ruby's own lookup order and because a framework's contract is as often a module as a base class:
|
|
300
|
+
# a Sidekiq worker is `class TriggerWebhookWorker; include Sidekiq::Job`, with no base class at all,
|
|
301
|
+
# so a superclass-only walk could not match a row about it however the plugin spelled one (#456).
|
|
302
|
+
# `ExpressionTyper#enqueue_ancestors` already resolves a self-call this way; the two tables
|
|
303
|
+
# disagreeing about what an ancestor is was the bug.
|
|
264
304
|
def ancestry(class_name)
|
|
265
305
|
@ancestry[class_name] ||= begin
|
|
266
306
|
chain = []
|
|
267
307
|
seen = Set.new
|
|
268
|
-
|
|
269
|
-
|
|
308
|
+
queue = [class_name]
|
|
309
|
+
until queue.empty? || chain.length >= ANCESTRY_CAP
|
|
310
|
+
current = queue.shift
|
|
311
|
+
next unless current && seen.add?(current)
|
|
312
|
+
|
|
270
313
|
chain << current
|
|
271
|
-
|
|
314
|
+
queue.concat(Array(@includes[current]).compact)
|
|
315
|
+
parent = Array(@superclasses[current]).first
|
|
316
|
+
queue << parent if parent
|
|
317
|
+
# Last, so a project's own declaration always wins the first-match race against a plugin's
|
|
318
|
+
# claim about a gem (#465).
|
|
319
|
+
queue.concat(Array(@declared_ancestry[current]))
|
|
272
320
|
end
|
|
273
321
|
chain.freeze
|
|
274
322
|
end
|
|
@@ -281,7 +329,8 @@ module Rigor
|
|
|
281
329
|
.map { |singleton| [singleton, sorted(@class_rows[singleton])] },
|
|
282
330
|
sorted(@path_rows), sorted(@self_rows), sorted(@result_rows),
|
|
283
331
|
@edges.map { |edge| [edge.target.to_s, edge.receiver, edge.selector.to_s, edge.plugin_id] }.sort,
|
|
284
|
-
@entry_points.map(&:to_h).sort_by { |preset| preset["name"] }
|
|
332
|
+
@entry_points.map(&:to_h).sort_by { |preset| preset["name"] },
|
|
333
|
+
@declared_ancestry.sort.map { |child, parents| [child, parents.sort] }
|
|
285
334
|
]
|
|
286
335
|
Digest::SHA256.hexdigest(payload.inspect)
|
|
287
336
|
end
|