rigortype 0.3.4 → 0.3.6
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 +5 -5
- data/data/effects/core.yml +18 -1
- data/data/effects/registry.yml +31 -3
- data/data/gem_overlay/activesupport/core_ext.rbs +33 -0
- data/docs/handbook/02-everyday-types.md +1 -1
- data/docs/handbook/04-tuples-and-shapes.md +1 -1
- data/docs/handbook/08-understanding-errors.md +1 -1
- data/docs/handbook/09-plugins.md +2 -2
- data/docs/handbook/10-sorbet.md +1 -1
- data/docs/handbook/README.md +2 -2
- data/docs/handbook/appendix-go.md +1 -1
- data/docs/handbook/appendix-java-csharp.md +2 -2
- data/docs/handbook/appendix-mypy.md +1 -1
- data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
- data/docs/handbook/appendix-rust.md +1 -1
- data/docs/handbook/appendix-type-theory.md +2 -2
- data/docs/handbook/appendix-typescript.md +4 -4
- data/docs/manual/02-cli-reference.md +73 -22
- data/docs/manual/03-configuration.md +19 -2
- data/docs/manual/04-diagnostics.md +15 -2
- data/docs/manual/08-skills.md +1 -1
- data/docs/manual/11-ci.md +38 -1
- data/docs/manual/12-caching.md +40 -1
- data/docs/manual/15-type-protection-coverage.md +1 -1
- data/docs/manual/16-rbs-extended-annotations.md +15 -2
- data/docs/manual/18-removing-dead-code.md +13 -4
- data/docs/manual/19-effect-labels.md +678 -0
- data/docs/manual/README.md +7 -2
- data/docs/manual/ci-templates/README.md +9 -0
- data/docs/manual/plugins/rigor-actionmailer.md +4 -4
- data/docs/manual/plugins/rigor-activejob.md +3 -3
- data/docs/manual/plugins/rigor-activerecord.md +4 -4
- data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
- data/docs/manual/plugins/rigor-rspec.md +6 -2
- data/lib/rigor/analysis/diagnostic.rb +17 -6
- data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
- data/lib/rigor/analysis/reachability/graph.rb +30 -11
- data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
- data/lib/rigor/analysis/reachability/scan.rb +20 -4
- data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
- data/lib/rigor/analysis/rule_catalog.rb +10 -3
- data/lib/rigor/analysis/run_cache_key.rb +12 -0
- 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 +15 -9
- 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 +163 -31
- data/lib/rigor/analysis/worker_session.rb +3 -1
- data/lib/rigor/cache/file_digest.rb +20 -2
- data/lib/rigor/cli/check_command.rb +67 -48
- data/lib/rigor/cli/coverage_command.rb +5 -6
- data/lib/rigor/cli/doc_links.rb +100 -0
- data/lib/rigor/cli/docs_command.rb +32 -2
- data/lib/rigor/cli/effects_command.rb +149 -10
- data/lib/rigor/cli/effects_diff_renderer.rb +88 -14
- data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
- 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 +75 -11
- data/lib/rigor/cli/unused_command.rb +65 -16
- data/lib/rigor/cli.rb +33 -9
- 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/definition_lines.rb +100 -0
- data/lib/rigor/effects/entry_points.rb +47 -1
- data/lib/rigor/effects/envelope_check.rb +18 -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/liskov_check.rb +17 -8
- 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 +29 -2
- data/lib/rigor/effects/snapshot.rb +74 -26
- data/lib/rigor/effects/snapshot_diff.rb +26 -3
- data/lib/rigor/effects/taint_cause.rb +1 -0
- data/lib/rigor/effects/unit_scan.rb +87 -12
- data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
- 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/envelope_scanner.rb +9 -0
- 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
- data/skills/rigor-ci-setup/SKILL.md +2 -2
- data/skills/rigor-editor-setup/SKILL.md +2 -2
- data/skills/rigor-mcp-setup/SKILL.md +2 -2
- data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
- data/skills/rigor-plugin-review/SKILL.md +3 -3
- metadata +10 -1
|
@@ -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
|
|
@@ -112,11 +112,13 @@ module Rigor
|
|
|
112
112
|
|
|
113
113
|
inherited = envelopes.fetch(ancestor_key)
|
|
114
114
|
own = envelopes[key]
|
|
115
|
-
position
|
|
115
|
+
# The position is read inside the two collectors, after they know a finding exists: `.for` is
|
|
116
|
+
# what forces a deferred position table, and an inherited envelope nothing widens must not
|
|
117
|
+
# cost a whole-project discovery parse.
|
|
116
118
|
if own && !own.top?
|
|
117
|
-
collect_declared(findings, key, ancestor_key, inherited, own,
|
|
119
|
+
collect_declared(findings, key, ancestor_key, inherited, own, positions)
|
|
118
120
|
else
|
|
119
|
-
collect_proven(findings, table, key, ancestor_key, inherited,
|
|
121
|
+
collect_proven(findings, table, key, ancestor_key, inherited, positions, apply_tolerated)
|
|
120
122
|
end
|
|
121
123
|
end
|
|
122
124
|
|
|
@@ -135,16 +137,19 @@ module Rigor
|
|
|
135
137
|
nil
|
|
136
138
|
end
|
|
137
139
|
|
|
138
|
-
def collect_proven(findings, table, key, ancestor_key, inherited,
|
|
140
|
+
def collect_proven(findings, table, key, ancestor_key, inherited, positions, apply_tolerated)
|
|
139
141
|
entry = table[key]
|
|
140
142
|
return if entry.nil?
|
|
141
143
|
|
|
142
144
|
exceeding = inherited.exceeded_by(apply_tolerated ? entry.undischarged : entry.proven)
|
|
145
|
+
return if exceeding.empty?
|
|
146
|
+
|
|
147
|
+
path, line = positions.for(key)
|
|
143
148
|
exceeding.each do |label|
|
|
144
149
|
trail = PathFinder.shortest(table, symbol: key, label: label)
|
|
145
150
|
findings << Finding.new(
|
|
146
151
|
key: key, label: label, ancestor_key: ancestor_key, ancestor_envelope: inherited,
|
|
147
|
-
own_envelope: nil, path:
|
|
152
|
+
own_envelope: nil, path: path, line: line,
|
|
148
153
|
chain: trail&.chain || [key].freeze, origin: trail&.origin
|
|
149
154
|
)
|
|
150
155
|
end
|
|
@@ -152,11 +157,15 @@ module Rigor
|
|
|
152
157
|
|
|
153
158
|
# Two authored bounds, compared by subsumption alone. `mutate.local` is tolerated here as it is
|
|
154
159
|
# everywhere, so declaring it under an inherited `%a{pure}` is not a widening.
|
|
155
|
-
def collect_declared(findings, key, ancestor_key, inherited, own,
|
|
156
|
-
own.bound.to_a.reject { |label| inherited.tolerates?(label) }
|
|
160
|
+
def collect_declared(findings, key, ancestor_key, inherited, own, positions)
|
|
161
|
+
widened = own.bound.to_a.reject { |label| inherited.tolerates?(label) }
|
|
162
|
+
return if widened.empty?
|
|
163
|
+
|
|
164
|
+
path, line = positions.for(key)
|
|
165
|
+
widened.each do |label|
|
|
157
166
|
findings << Finding.new(
|
|
158
167
|
key: key, label: label, ancestor_key: ancestor_key, ancestor_envelope: inherited,
|
|
159
|
-
own_envelope: own, path:
|
|
168
|
+
own_envelope: own, path: path, line: line, chain: nil, origin: nil
|
|
160
169
|
)
|
|
161
170
|
end
|
|
162
171
|
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
|
|
@@ -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
|