rigortype 0.3.3 → 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/builtins/ruby_core/array.yml +11 -24
- data/data/builtins/ruby_core/enumerable.yml +4 -8
- data/data/builtins/ruby_core/exception.yml +6 -9
- data/data/builtins/ruby_core/hash.yml +13 -20
- data/data/builtins/ruby_core/io.yml +6 -9
- data/data/builtins/ruby_core/numeric.yml +2 -3
- data/data/builtins/ruby_core/proc.yml +4 -8
- data/data/builtins/ruby_core/re.yml +6 -9
- data/data/builtins/ruby_core/set.yml +2 -3
- data/data/builtins/ruby_core/string.yml +4 -7
- data/data/builtins/ruby_core/struct.yml +0 -6
- data/data/builtins/ruby_core/time.yml +3 -6
- data/data/effects/core.yml +860 -0
- data/data/effects/registry.yml +95 -0
- data/docs/handbook/07-rbs-and-extended.md +106 -5
- data/docs/manual/02-cli-reference.md +359 -2
- data/docs/manual/03-configuration.md +101 -1
- data/docs/manual/04-diagnostics.md +8 -3
- data/docs/manual/11-ci.md +37 -0
- data/docs/manual/12-caching.md +39 -0
- data/docs/manual/16-rbs-extended-annotations.md +90 -0
- data/docs/manual/18-removing-dead-code.md +326 -0
- data/docs/manual/19-effect-labels.md +671 -0
- data/docs/manual/README.md +10 -0
- data/docs/manual/ci-templates/README.md +9 -0
- data/docs/manual/plugins/rigor-actionmailer.md +5 -0
- data/docs/manual/plugins/rigor-activejob.md +47 -0
- data/docs/manual/plugins/rigor-factorybot.md +16 -0
- data/docs/manual/plugins/rigor-pundit.md +26 -0
- data/docs/manual/plugins/rigor-rails-routes.md +41 -2
- data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
- data/docs/manual/plugins/rigor-rspec.md +8 -0
- data/docs/manual/plugins/rigor-sidekiq.md +54 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
- data/lib/rigor/analysis/incremental_session.rb +140 -6
- data/lib/rigor/analysis/reachability/graph.rb +244 -0
- data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
- data/lib/rigor/analysis/reachability/project_files.rb +58 -0
- data/lib/rigor/analysis/reachability/scan.rb +241 -0
- data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
- data/lib/rigor/analysis/rule_catalog.rb +168 -3
- data/lib/rigor/analysis/run_cache_key.rb +16 -0
- data/lib/rigor/analysis/run_cache_probe.rb +69 -1
- data/lib/rigor/analysis/runner/declaration_position.rb +38 -0
- data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
- data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +93 -10
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
- data/lib/rigor/analysis/runner.rb +429 -10
- data/lib/rigor/analysis/worker_session.rb +60 -2
- data/lib/rigor/bleeding_edge.rb +22 -0
- data/lib/rigor/cache/incremental_snapshot.rb +24 -5
- data/lib/rigor/cli/check_command.rb +13 -1
- data/lib/rigor/cli/check_runner_factory.rb +4 -1
- data/lib/rigor/cli/effects_command.rb +245 -0
- data/lib/rigor/cli/effects_diff_renderer.rb +127 -0
- data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
- data/lib/rigor/cli/effects_renderer.rb +92 -0
- data/lib/rigor/cli/effects_report.rb +163 -0
- data/lib/rigor/cli/effects_snapshot_command.rb +275 -0
- data/lib/rigor/cli/unused_command.rb +288 -0
- data/lib/rigor/cli.rb +28 -1
- data/lib/rigor/configuration/severity_profile.rb +23 -0
- data/lib/rigor/configuration.rb +322 -28
- data/lib/rigor/configuration_error.rb +20 -0
- data/lib/rigor/effects/attribution.rb +76 -0
- data/lib/rigor/effects/catalog.rb +275 -0
- data/lib/rigor/effects/collector.rb +219 -0
- data/lib/rigor/effects/config_envelopes.rb +185 -0
- data/lib/rigor/effects/discharge.rb +69 -0
- data/lib/rigor/effects/effect_table.rb +92 -0
- data/lib/rigor/effects/entry_points.rb +139 -0
- data/lib/rigor/effects/envelope.rb +86 -0
- data/lib/rigor/effects/envelope_check.rb +172 -0
- data/lib/rigor/effects/envelope_index.rb +157 -0
- data/lib/rigor/effects/file_collection.rb +174 -0
- data/lib/rigor/effects/framework_units.rb +222 -0
- data/lib/rigor/effects/identity.rb +104 -0
- data/lib/rigor/effects/inline_anchor.rb +134 -0
- data/lib/rigor/effects/label.rb +77 -0
- data/lib/rigor/effects/label_intent.rb +73 -0
- data/lib/rigor/effects/label_set.rb +136 -0
- data/lib/rigor/effects/liskov_check.rb +167 -0
- data/lib/rigor/effects/local_ownership.rb +132 -0
- data/lib/rigor/effects/method_key.rb +40 -0
- data/lib/rigor/effects/mutation_classifier.rb +92 -0
- data/lib/rigor/effects/narrowing.rb +202 -0
- data/lib/rigor/effects/origin.rb +66 -0
- data/lib/rigor/effects/path_finder.rb +89 -0
- data/lib/rigor/effects/plugin_facts.rb +384 -0
- data/lib/rigor/effects/propagator.rb +335 -0
- data/lib/rigor/effects/registry.rb +200 -0
- data/lib/rigor/effects/scanner.rb +302 -0
- data/lib/rigor/effects/signature_sources.rb +90 -0
- data/lib/rigor/effects/snapshot.rb +396 -0
- data/lib/rigor/effects/snapshot_diff.rb +265 -0
- data/lib/rigor/effects/summary.rb +154 -0
- data/lib/rigor/effects/taint_cause.rb +39 -0
- data/lib/rigor/effects/unit_scan.rb +647 -0
- data/lib/rigor/effects/unknown_label_check.rb +86 -0
- data/lib/rigor/effects/unknown_label_report.rb +59 -0
- data/lib/rigor/effects.rb +47 -0
- data/lib/rigor/environment/rbs_loader.rb +34 -0
- data/lib/rigor/environment.rb +8 -1
- data/lib/rigor/flow_contribution/element.rb +1 -0
- data/lib/rigor/flow_contribution/merge_result.rb +5 -3
- data/lib/rigor/flow_contribution/merger.rb +16 -1
- data/lib/rigor/flow_contribution.rb +20 -4
- data/lib/rigor/inference/expression_typer.rb +18 -4
- data/lib/rigor/inference/pre_eval_constants.rb +189 -0
- data/lib/rigor/inference/scope_indexer.rb +15 -3
- data/lib/rigor/plugin/base.rb +28 -0
- data/lib/rigor/plugin/box.rb +18 -2
- data/lib/rigor/plugin/effect_ancestry.rb +80 -0
- data/lib/rigor/plugin/effect_attribution.rb +208 -0
- data/lib/rigor/plugin/effect_edge.rb +101 -0
- data/lib/rigor/plugin/effect_entry_points.rb +51 -0
- data/lib/rigor/plugin/first_party.rb +57 -0
- data/lib/rigor/plugin/isolation.rb +4 -1
- data/lib/rigor/plugin/manifest.rb +127 -3
- data/lib/rigor/plugin/registry.rb +55 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
- data/lib/rigor/rbs_extended.rb +188 -0
- data/lib/rigor/reflection.rb +131 -30
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +401 -29
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
- data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
- data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
- data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
- data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
- data/plugins/rigor-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/schedule_scan.rb +122 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +68 -3
- data/sig/rigor/analysis/fact_store.rbs +1 -0
- data/sig/rigor/inference.rbs +2 -0
- data/sig/rigor/rbs_extended.rbs +6 -0
- data/sig/rigor.rbs +17 -1
- data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
- metadata +84 -1
|
@@ -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
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Effects
|
|
5
|
+
# The effect-label grammar and the subsumption relation over it (ADR-103 WD1; normative in
|
|
6
|
+
# `docs/type-specification/effect-labels.md`).
|
|
7
|
+
#
|
|
8
|
+
# A label is a dot-path of lowercase segments — `io`, `io.net.http`, `nondet.time`. The relation
|
|
9
|
+
# that matters is **segment-aware prefix subsumption**: `io` admits `io.net.http` and rejects
|
|
10
|
+
# `iota`. Every method here is pure and total; a malformed input is answered, never raised on,
|
|
11
|
+
# so a caller can ask `valid?` and the rest in either order.
|
|
12
|
+
module Label
|
|
13
|
+
# `label = segment { "." segment }`, `segment = [a-z][a-z0-9]*`. Deliberately narrow: no
|
|
14
|
+
# underscores, no hyphens, no uppercase, no empty segment, no trailing dot. The same grammar
|
|
15
|
+
# is the RFC's `label` production, so a label spells identically in Steins and Rigor.
|
|
16
|
+
PATTERN = /\A[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)*\z/
|
|
17
|
+
|
|
18
|
+
SEPARATOR = "."
|
|
19
|
+
private_constant :SEPARATOR
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Whether `str` is a well-formed label. Anything that is not a String is not.
|
|
24
|
+
def valid?(str)
|
|
25
|
+
str.is_a?(String) && PATTERN.match?(str)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The label's segments, outermost first: `"io.net.http"` -> `["io", "net", "http"]`.
|
|
29
|
+
# A malformed label yields an empty array rather than a partial parse.
|
|
30
|
+
def segments(label)
|
|
31
|
+
return [].freeze unless valid?(label)
|
|
32
|
+
|
|
33
|
+
label.split(SEPARATOR).freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Whether `bound` admits `label` under segment-aware prefix subsumption. A label subsumes
|
|
37
|
+
# itself; `io` subsumes `io.net.http`; `io` does NOT subsume `iota`, because the match is on
|
|
38
|
+
# segment boundaries and not on characters.
|
|
39
|
+
def subsumes?(bound, label)
|
|
40
|
+
return false unless valid?(bound) && valid?(label)
|
|
41
|
+
return true if bound == label
|
|
42
|
+
|
|
43
|
+
label.start_with?("#{bound}#{SEPARATOR}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The label one segment shallower, or `nil` for a root (and for a malformed label).
|
|
47
|
+
def parent(label)
|
|
48
|
+
return nil unless valid?(label)
|
|
49
|
+
|
|
50
|
+
index = label.rindex(SEPARATOR)
|
|
51
|
+
return nil unless index
|
|
52
|
+
|
|
53
|
+
label[0, index]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The label's proper ancestors, outermost first and excluding the label itself:
|
|
57
|
+
# `"io.net.http"` -> `["io", "io.net"]`. A root has no ancestors.
|
|
58
|
+
def ancestors(label)
|
|
59
|
+
parts = segments(label)
|
|
60
|
+
return [].freeze if parts.length <= 1
|
|
61
|
+
|
|
62
|
+
result = []
|
|
63
|
+
prefix = nil
|
|
64
|
+
parts[0...-1].each do |segment|
|
|
65
|
+
prefix = prefix ? "#{prefix}#{SEPARATOR}#{segment}" : segment
|
|
66
|
+
result << prefix
|
|
67
|
+
end
|
|
68
|
+
result.freeze
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The label's outermost segment — the root whose ownership the registry checks.
|
|
72
|
+
def root(label)
|
|
73
|
+
segments(label).first
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "label"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Effects
|
|
7
|
+
# Whether an unrecognised effect label is evidence of a **typo** rather than of a word that was
|
|
8
|
+
# never meant to be a label at all (ADR-103 WD1; normative in
|
|
9
|
+
# `docs/type-specification/effect-labels.md` § Unknown labels).
|
|
10
|
+
#
|
|
11
|
+
# The degradation an unknown label causes is silent by construction — the tag reads ⊤ and stops
|
|
12
|
+
# bounding anything — so a paired diagnostic is the only thing that keeps the fail-open rule
|
|
13
|
+
# honest. But the diagnostic cannot fire on every unrecognised spelling: a vocabulary is open by
|
|
14
|
+
# design (`effects.labels:`, a plugin's own root), so an unknown word is as likely to be a label
|
|
15
|
+
# this project has not registered yet as it is to be a misspelling. Reporting both would put a
|
|
16
|
+
# finding on correct-by-intent code, which is the direction the false-positive budget is not
|
|
17
|
+
# allowed to run ([ADR-5](../../../docs/adr/5-robustness-principle.md)).
|
|
18
|
+
#
|
|
19
|
+
# Intent is therefore read off four signals, any one of which is enough:
|
|
20
|
+
#
|
|
21
|
+
# 1. **A near miss** — the spelling is within {Registry::SUGGESTION_DISTANCE_CAP} edits of a
|
|
22
|
+
# label the registry knows (`io.bd` against `io.db`).
|
|
23
|
+
# 2. **A known sibling** — another member of the same comma-separated list is recognised, so the
|
|
24
|
+
# list as a whole is demonstrably written in this vocabulary.
|
|
25
|
+
# 3. **A dotted path** — the token carries two or more segments (`io.netw`). Nothing but a label
|
|
26
|
+
# is spelled that way; a project opening its own root writes a bare word first.
|
|
27
|
+
# 4. **A retired spelling** — the registry's `retired:` table names it, so the author wrote a
|
|
28
|
+
# label that WAS correct and a vocabulary bump moved it.
|
|
29
|
+
#
|
|
30
|
+
# A lone far-off word (`%a{rigor:v1:effect database}`) matches none of them and stays silent
|
|
31
|
+
# everywhere. It still degrades the tag to ⊤ — the reading never depends on this module.
|
|
32
|
+
module LabelIntent
|
|
33
|
+
# How many dot-separated segments make a token unmistakably label-shaped (signal 3).
|
|
34
|
+
MULTI_SEGMENT_ARITY = 2
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# Whether reporting `token` as an unknown label is justified.
|
|
39
|
+
#
|
|
40
|
+
# @param token [String] the spelling as written.
|
|
41
|
+
# @param registry [Rigor::Effects::Registry, nil] the vocabulary AFTER plugin load; `nil` (no
|
|
42
|
+
# vocabulary at all) makes every token unjudgeable and therefore silent.
|
|
43
|
+
# @param siblings [Array<String>] the other tokens of the same list / the same config value.
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
def evident?(token, registry, siblings: [])
|
|
46
|
+
return false if registry.nil?
|
|
47
|
+
return false unless Label.valid?(token)
|
|
48
|
+
return false if registry.known?(token)
|
|
49
|
+
|
|
50
|
+
retired?(token, registry) || near_miss?(token, registry) ||
|
|
51
|
+
multi_segment?(token) || known_sibling?(token, registry, siblings)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def retired?(token, registry)
|
|
55
|
+
!Array(registry.retired(token)).empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def near_miss?(token, registry)
|
|
59
|
+
!registry.suggest(token).nil?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def multi_segment?(token)
|
|
63
|
+
Label.segments(token).length >= MULTI_SEGMENT_ARITY
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def known_sibling?(token, registry, siblings)
|
|
67
|
+
Array(siblings).any? { |sibling| sibling != token && registry.known?(sibling.to_s) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private_class_method :retired?, :near_miss?, :multi_segment?, :known_sibling?
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "label"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Effects
|
|
7
|
+
# An immutable, sorted, de-duplicated set of effect labels — the carrier of a summary lane and
|
|
8
|
+
# of an envelope's bound (ADR-103 WD1).
|
|
9
|
+
#
|
|
10
|
+
# Two sentinels bracket the lattice. {EMPTY} is the empty set: "no effects", the reading of
|
|
11
|
+
# `%a{pure}` modulo `mutate.local`. {TOP} is the unbounded / unspecified reading: it admits
|
|
12
|
+
# every label, it absorbs every join, and it is what a tag carrying an unknown label degrades
|
|
13
|
+
# to (the fail-open rule). {TOP} is NOT the set of all registered labels and is deliberately
|
|
14
|
+
# not enumerable — `top?` is the only way to tell it from {EMPTY} through `to_a`.
|
|
15
|
+
#
|
|
16
|
+
# Instances are frozen on construction and hold a frozen array, so they cross a Ractor boundary
|
|
17
|
+
# and go into a cache entry as they are.
|
|
18
|
+
class LabelSet
|
|
19
|
+
NO_LABELS = [].freeze
|
|
20
|
+
private_constant :NO_LABELS
|
|
21
|
+
|
|
22
|
+
# Build a set from any enumerable of label strings. Members are normalised (de-duplicated and
|
|
23
|
+
# sorted) so equality is structural and a snapshot rendering is deterministic.
|
|
24
|
+
#
|
|
25
|
+
# Raises `ArgumentError` on a member that does not satisfy {Label::PATTERN}: a LabelSet is a
|
|
26
|
+
# value object over the grammar, and the fail-open handling of an unrecognised *spelling* is
|
|
27
|
+
# the reader's job (it yields {TOP}), not this constructor's.
|
|
28
|
+
#
|
|
29
|
+
# `top:` is internal — it exists to build {TOP} and is not part of the surface later slices
|
|
30
|
+
# build on.
|
|
31
|
+
def initialize(labels = NO_LABELS, top: false)
|
|
32
|
+
@top = top
|
|
33
|
+
@labels = NO_LABELS
|
|
34
|
+
unless top
|
|
35
|
+
members = labels.to_a
|
|
36
|
+
members.each do |label|
|
|
37
|
+
raise ArgumentError, "not a well-formed effect label: #{label.inspect}" unless Label.valid?(label)
|
|
38
|
+
end
|
|
39
|
+
@labels = members.uniq.sort.freeze
|
|
40
|
+
end
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The unbounded set. Everything is subsumed by it; joining it with anything yields it.
|
|
45
|
+
TOP = new(NO_LABELS, top: true)
|
|
46
|
+
|
|
47
|
+
# The empty set — no effects at all.
|
|
48
|
+
EMPTY = new(NO_LABELS)
|
|
49
|
+
|
|
50
|
+
# Whether this is the unbounded sentinel {TOP}.
|
|
51
|
+
def top?
|
|
52
|
+
@top
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Whether this set records no labels. {TOP} is not empty: it records nothing because it
|
|
56
|
+
# stands for everything.
|
|
57
|
+
def empty?
|
|
58
|
+
!@top && @labels.empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The members, sorted, as a frozen array. {TOP} yields `[]` — consult {top?} first.
|
|
62
|
+
def to_a
|
|
63
|
+
@labels
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Exact membership among the recorded labels. `LabelSet.new(["io"]).include?("io.net")` is
|
|
67
|
+
# false — that question is {admits?}. {TOP} records no members, so it includes none.
|
|
68
|
+
def include?(label)
|
|
69
|
+
@labels.include?(label)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Whether some member of this set subsumes `label`. {TOP} admits every well-formed label.
|
|
73
|
+
def admits?(label)
|
|
74
|
+
return Label.valid?(label) if @top
|
|
75
|
+
|
|
76
|
+
@labels.any? { |member| Label.subsumes?(member, label) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Union. {TOP} absorbs: a join involving it is {TOP}.
|
|
80
|
+
#
|
|
81
|
+
# A join that adds nothing returns `self` **without allocating**. That is the common case wherever
|
|
82
|
+
# sets are joined in a loop — the propagator's fixpoint re-joins every edge on every visit — and the
|
|
83
|
+
# sets are single-digit-sized, so the containment scan is cheaper than the construction it avoids.
|
|
84
|
+
def join(other)
|
|
85
|
+
return TOP if @top || other.top?
|
|
86
|
+
return other if @labels.empty?
|
|
87
|
+
|
|
88
|
+
others = other.to_a
|
|
89
|
+
return self if others.empty?
|
|
90
|
+
return self if others.all? { |label| @labels.include?(label) }
|
|
91
|
+
|
|
92
|
+
self.class.new(@labels + others)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The members `other` does not already admit — the **declared lane's rendering rule**. A declared
|
|
96
|
+
# `io.net.http` beside a proven `io.net` says nothing the proven lane did not already say more
|
|
97
|
+
# strongly, so a renderer drops it rather than printing `[io.net] ≤ [io.net.http]`, which reads as
|
|
98
|
+
# two facts where there is one. Applied where output is produced, never to the table: the lanes
|
|
99
|
+
# themselves stay raw, because a later join has to see what was actually declared.
|
|
100
|
+
def excluding_subsumed_by(other)
|
|
101
|
+
return self if @top || other.empty?
|
|
102
|
+
|
|
103
|
+
kept = @labels.reject { |label| other.admits?(label) }
|
|
104
|
+
return self if kept.length == @labels.length
|
|
105
|
+
|
|
106
|
+
kept.empty? ? EMPTY : self.class.new(kept)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Whether every member of this set is admitted by `bound_set` — the envelope check, modulo
|
|
110
|
+
# the policy discharge that happens at judgment time. {TOP} is bounded only by {TOP}.
|
|
111
|
+
def subsumed_by?(bound_set)
|
|
112
|
+
return true if bound_set.top?
|
|
113
|
+
return false if @top
|
|
114
|
+
|
|
115
|
+
@labels.all? { |label| bound_set.admits?(label) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def ==(other)
|
|
119
|
+
return true if equal?(other)
|
|
120
|
+
|
|
121
|
+
other.is_a?(LabelSet) && other.top? == @top && other.to_a == @labels
|
|
122
|
+
end
|
|
123
|
+
alias eql? ==
|
|
124
|
+
|
|
125
|
+
def hash
|
|
126
|
+
[self.class, @top, @labels].hash
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def inspect
|
|
130
|
+
return "#<Rigor::Effects::LabelSet TOP>" if @top
|
|
131
|
+
|
|
132
|
+
"#<Rigor::Effects::LabelSet #{@labels.join(', ')}>"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "envelope"
|
|
4
|
+
require_relative "envelope_check"
|
|
5
|
+
require_relative "method_key"
|
|
6
|
+
require_relative "path_finder"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Effects
|
|
10
|
+
# Judges an override against the envelope it inherits (ADR-103 WD1 / WD14; #386). The one place
|
|
11
|
+
# `effect.liskov-widened` is decided.
|
|
12
|
+
#
|
|
13
|
+
# An envelope is a contract about a method, and Ruby's `class PgRepo < Repo` says a `PgRepo` is
|
|
14
|
+
# usable wherever a `Repo` is. So a bound written on `Repo#find` binds `PgRepo#find` too:
|
|
15
|
+
# **implementations may be purer than the bound they inherit, never less pure.** That is Liskov
|
|
16
|
+
# inclusion applied to the second dimension, and it is what makes the declared lane's nominal carrier
|
|
17
|
+
# (#386, {EnvelopeIndex}) honest — a caller that imports `≤ io.db` from a `Repo`-typed receiver is
|
|
18
|
+
# entitled to that bound whichever subclass actually arrives.
|
|
19
|
+
#
|
|
20
|
+
# Two comparisons, and an override is subject to exactly one of them:
|
|
21
|
+
#
|
|
22
|
+
# - **Proven against the inherited bound** — the override declares nothing of its own, so what it
|
|
23
|
+
# *does* is what the ancestor's bound has to admit. It reads the same lane
|
|
24
|
+
# {EnvelopeCheck} reads, for the same reasons: the proven closure, undischarged per policy,
|
|
25
|
+
# `mutate.local` tolerated, taint ignored.
|
|
26
|
+
# - **Declared against the inherited bound** — the override declares its own envelope, and a bound
|
|
27
|
+
# wider than the one it inherits is a Liskov violation in the *declaration*, before any body is
|
|
28
|
+
# consulted. Two authored bounds compared by subsumption; nothing proven enters it.
|
|
29
|
+
#
|
|
30
|
+
# The split is exclusive on purpose. An override that declares its own envelope is already held to
|
|
31
|
+
# that envelope by `effect.envelope-exceeded`, so running the proven comparison too would put two
|
|
32
|
+
# diagnostics on one line for one label. What the author asserted is the thing Liskov has to judge;
|
|
33
|
+
# whether the body honours the assertion is the other rule's question.
|
|
34
|
+
#
|
|
35
|
+
# Both-sides-authored, in the [ADR-35](../../../docs/adr/35-override-signature-compatibility.md)
|
|
36
|
+
# sense: nothing fires unless an author wrote an envelope on the ancestor. That is the accepted
|
|
37
|
+
# construction the false-positive budget is spent under — a firing is never unsolicited.
|
|
38
|
+
#
|
|
39
|
+
# **Nominal subclassing only.** An included module's method is not an override in this slice: Ruby's
|
|
40
|
+
# ancestry puts an includer's own `def` *ahead* of the module's rather than under it, and the
|
|
41
|
+
# substitutability argument that licenses the check is the subclass one. The `superclasses` table is
|
|
42
|
+
# the collector's own, as-written and resolved here exactly as {Propagator} resolves it, so the
|
|
43
|
+
# relation the check reads and the closed world the proven lane travels can never disagree.
|
|
44
|
+
module LiskovCheck
|
|
45
|
+
# One (override, exceeding label) pair.
|
|
46
|
+
#
|
|
47
|
+
# `own_envelope` is the override's own bound when there is one, which is also what selects the
|
|
48
|
+
# message variant: present means the declaration-level comparison produced this finding, nil means
|
|
49
|
+
# the proven one. `chain` / `origin` explain the proven variant and are nil for the other, which
|
|
50
|
+
# has no path to walk — a declaration is not proved by anything.
|
|
51
|
+
Finding = Data.define(:key, :label, :ancestor_key, :ancestor_envelope, :own_envelope, :path, :line,
|
|
52
|
+
:chain, :origin)
|
|
53
|
+
|
|
54
|
+
NO_FINDINGS = [].freeze
|
|
55
|
+
private_constant :NO_FINDINGS
|
|
56
|
+
|
|
57
|
+
module_function
|
|
58
|
+
|
|
59
|
+
# @param table [EffectTable] the run's propagated graph.
|
|
60
|
+
# @param superclasses [Hash{String => Array<String>}] the collector's as-written superclass
|
|
61
|
+
# candidate lists (`FileCollection#superclasses`).
|
|
62
|
+
# @param method_envelopes [Hash{String => Envelope}] per-method envelopes, as written.
|
|
63
|
+
# @param class_envelopes [Hash{String => Envelope}] class- / module-level envelopes, to distribute.
|
|
64
|
+
# @param config_envelopes [Hash{String => Envelope}] `effects.envelopes:` entries already resolved
|
|
65
|
+
# to the classes they select.
|
|
66
|
+
# @param positions [EnvelopeCheck::Positions] where the override's `def` is.
|
|
67
|
+
# @param apply_tolerated [Boolean] false judges against `proven` — `--no-tolerated-effects`.
|
|
68
|
+
# @return [Array<Finding>] sorted by position then key then label.
|
|
69
|
+
def run(table:, superclasses:, method_envelopes:, class_envelopes:, config_envelopes: {},
|
|
70
|
+
positions: EnvelopeCheck::Positions.empty, apply_tolerated: true)
|
|
71
|
+
# The distributed strata, over a base of the raw per-method annotations: a base class whose method
|
|
72
|
+
# exists only in `.rbs` — an abstract `def find: (Integer) -> User` with no Ruby body — has no
|
|
73
|
+
# key in the table and so no distributed entry, and its bound is exactly the one an override
|
|
74
|
+
# inherits. Distribution wins on collision, which is the same value for a key that has both.
|
|
75
|
+
envelopes = method_envelopes.merge(
|
|
76
|
+
EnvelopeCheck.distribute(table, method_envelopes, class_envelopes, config_envelopes)
|
|
77
|
+
)
|
|
78
|
+
return NO_FINDINGS if envelopes.empty?
|
|
79
|
+
|
|
80
|
+
parents = parent_map(table, superclasses)
|
|
81
|
+
return NO_FINDINGS if parents.empty?
|
|
82
|
+
|
|
83
|
+
findings = []
|
|
84
|
+
keys = table.keys
|
|
85
|
+
keys.each { |key| collect(findings, table, key, envelopes, parents, positions, apply_tolerated) }
|
|
86
|
+
findings.sort_by { |f| [f.path.to_s, f.line, f.key, f.label] }.freeze
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# `{child class => parent class}`, one parent per child.
|
|
90
|
+
#
|
|
91
|
+
# An ancestry name is recorded as written, so `class Loud < Base` inside `module Tracer` arrives as
|
|
92
|
+
# the candidate list Ruby's own lexical lookup would try, most-qualified first. The most-qualified
|
|
93
|
+
# candidate the project actually defines wins — the same rule {Propagator::Index#build_descendants}
|
|
94
|
+
# applies, and for the same reason: without it `A::Base` and `B::Base` would share the bare spelling
|
|
95
|
+
# `Base` and an unrelated class's envelope would bind an override that never inherited it.
|
|
96
|
+
def parent_map(table, superclasses)
|
|
97
|
+
return {} if superclasses.nil? || superclasses.empty?
|
|
98
|
+
|
|
99
|
+
known = table.keys.filter_map { |key| MethodKey.owner(key) }.to_set
|
|
100
|
+
superclasses.each_with_object({}) do |(child, candidates), out|
|
|
101
|
+
parent = Array(candidates).find { |candidate| known.include?(candidate) }
|
|
102
|
+
out[child] = parent if parent && parent != child
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def collect(findings, table, key, envelopes, parents, positions, apply_tolerated)
|
|
107
|
+
class_name, separator, selector = MethodKey.split(key)
|
|
108
|
+
return if class_name.nil?
|
|
109
|
+
|
|
110
|
+
ancestor_key = inherited_key(class_name, separator, selector, envelopes, parents)
|
|
111
|
+
return if ancestor_key.nil?
|
|
112
|
+
|
|
113
|
+
inherited = envelopes.fetch(ancestor_key)
|
|
114
|
+
own = envelopes[key]
|
|
115
|
+
position = positions.for(key)
|
|
116
|
+
if own && !own.top?
|
|
117
|
+
collect_declared(findings, key, ancestor_key, inherited, own, position)
|
|
118
|
+
else
|
|
119
|
+
collect_proven(findings, table, key, ancestor_key, inherited, position, apply_tolerated)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The nearest ancestor whose own method key carries an envelope. Nearest wins for the same reason
|
|
124
|
+
# it does among strata: a bound written closer to the override is the more specific statement about
|
|
125
|
+
# it, and a grandparent's bound is already binding on the parent that sits between them.
|
|
126
|
+
def inherited_key(class_name, separator, selector, envelopes, parents)
|
|
127
|
+
seen = Set.new([class_name])
|
|
128
|
+
current = parents[class_name]
|
|
129
|
+
while current && seen.add?(current)
|
|
130
|
+
candidate = "#{current}#{separator}#{selector}"
|
|
131
|
+
return candidate if envelopes.key?(candidate) && !envelopes.fetch(candidate).top?
|
|
132
|
+
|
|
133
|
+
current = parents[current]
|
|
134
|
+
end
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def collect_proven(findings, table, key, ancestor_key, inherited, position, apply_tolerated)
|
|
139
|
+
entry = table[key]
|
|
140
|
+
return if entry.nil?
|
|
141
|
+
|
|
142
|
+
exceeding = inherited.exceeded_by(apply_tolerated ? entry.undischarged : entry.proven)
|
|
143
|
+
exceeding.each do |label|
|
|
144
|
+
trail = PathFinder.shortest(table, symbol: key, label: label)
|
|
145
|
+
findings << Finding.new(
|
|
146
|
+
key: key, label: label, ancestor_key: ancestor_key, ancestor_envelope: inherited,
|
|
147
|
+
own_envelope: nil, path: position.first, line: position.last,
|
|
148
|
+
chain: trail&.chain || [key].freeze, origin: trail&.origin
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Two authored bounds, compared by subsumption alone. `mutate.local` is tolerated here as it is
|
|
154
|
+
# everywhere, so declaring it under an inherited `%a{pure}` is not a widening.
|
|
155
|
+
def collect_declared(findings, key, ancestor_key, inherited, own, position)
|
|
156
|
+
own.bound.to_a.reject { |label| inherited.tolerates?(label) }.each do |label|
|
|
157
|
+
findings << Finding.new(
|
|
158
|
+
key: key, label: label, ancestor_key: ancestor_key, ancestor_envelope: inherited,
|
|
159
|
+
own_envelope: own, path: position.first, line: position.last, chain: nil, origin: nil
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private_class_method :parent_map, :collect, :inherited_key, :collect_proven, :collect_declared
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|