rigortype 0.4.4 → 0.4.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 +5 -5
- data/docs/handbook/11-sig-gen.md +90 -17
- data/docs/manual/02-cli-reference.md +39 -9
- data/docs/manual/03-configuration.md +10 -8
- data/docs/manual/04-diagnostics.md +16 -5
- data/docs/manual/05-inspecting-types.md +2 -2
- data/docs/manual/06-baseline.md +1 -1
- data/docs/manual/07-plugins.md +2 -1
- data/docs/manual/08-skills.md +7 -0
- data/docs/manual/10-mcp-server.md +8 -1
- data/docs/manual/18-removing-dead-code.md +18 -7
- data/docs/manual/plugins/rigor-rspec.md +101 -1
- data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
- data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
- data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
- data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
- data/lib/rigor/analysis/check_rules.rb +228 -15
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/incremental.rb +49 -0
- data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
- data/lib/rigor/analysis/incremental_session.rb +166 -24
- data/lib/rigor/analysis/path_expansion.rb +3 -1
- data/lib/rigor/analysis/reachability/graph.rb +90 -13
- data/lib/rigor/analysis/reachability/scan.rb +279 -39
- data/lib/rigor/analysis/rule_catalog.rb +17 -4
- data/lib/rigor/analysis/runner/pool_coordinator.rb +69 -16
- data/lib/rigor/analysis/runner.rb +69 -65
- data/lib/rigor/analysis/severity_stamp.rb +11 -0
- data/lib/rigor/analysis/worker_session.rb +12 -1
- data/lib/rigor/cache/descriptor.rb +40 -7
- data/lib/rigor/cache/engine_source.rb +3 -2
- data/lib/rigor/cache/incremental_snapshot.rb +26 -8
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +4 -3
- data/lib/rigor/cli/command.rb +3 -1
- data/lib/rigor/cli/docs_command.rb +3 -2
- data/lib/rigor/cli/doctor_command.rb +1 -1
- data/lib/rigor/cli/explain_command.rb +3 -2
- data/lib/rigor/cli/plugin_command.rb +2 -1
- data/lib/rigor/cli/plugins_command.rb +2 -1
- data/lib/rigor/cli/sig_gen_command.rb +32 -11
- data/lib/rigor/cli/skill_command.rb +2 -1
- data/lib/rigor/cli/skill_describe.rb +3 -2
- data/lib/rigor/cli/unused_command.rb +10 -7
- data/lib/rigor/cli.rb +3 -2
- data/lib/rigor/config_audit.rb +60 -15
- data/lib/rigor/configuration/severity_profile.rb +20 -2
- data/lib/rigor/configuration.rb +29 -3
- data/lib/rigor/effects/signature_sources.rb +3 -1
- data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
- data/lib/rigor/environment/gated_signature_guard.rb +2 -1
- data/lib/rigor/environment/gated_signature_plan.rb +2 -1
- data/lib/rigor/environment/installed_gem_set.rb +2 -1
- data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
- data/lib/rigor/environment/rbs_loader.rb +21 -1
- data/lib/rigor/inference/block_call_timing.rb +7 -1
- data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
- data/lib/rigor/inference/captured_locals.rb +44 -13
- data/lib/rigor/inference/clause_widening.rb +90 -0
- data/lib/rigor/inference/definer_resolution.rb +87 -20
- data/lib/rigor/inference/expression_typer.rb +35 -5
- data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
- data/lib/rigor/inference/macro_block_self_type.rb +20 -6
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
- data/lib/rigor/inference/method_dispatcher.rb +67 -3
- data/lib/rigor/inference/module_function_state.rb +137 -42
- data/lib/rigor/inference/narrowing.rb +54 -10
- data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
- data/lib/rigor/inference/project_patched_methods.rb +168 -4
- data/lib/rigor/inference/project_patched_mixins.rb +162 -0
- data/lib/rigor/inference/project_patched_scanner.rb +51 -12
- data/lib/rigor/inference/project_patched_visibility.rb +214 -0
- data/lib/rigor/inference/project_seed_tables.rb +120 -0
- data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
- data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
- data/lib/rigor/inference/return_barrier.rb +16 -0
- data/lib/rigor/inference/scope_indexer.rb +675 -105
- data/lib/rigor/inference/self_call_ivar_writes.rb +11 -6
- data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
- data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
- data/lib/rigor/inference/statement_evaluator.rb +446 -139
- data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
- data/lib/rigor/lens/contract_reader.rb +45 -2
- data/lib/rigor/lens/document.rb +2 -1
- data/lib/rigor/lens/file_reader.rb +12 -2
- data/lib/rigor/lens/members.rb +16 -2
- data/lib/rigor/lens/run.rb +34 -7
- data/lib/rigor/mcp/server.rb +2 -2
- data/lib/rigor/plugin/base.rb +3 -2
- data/lib/rigor/plugin/io_boundary.rb +42 -6
- data/lib/rigor/plugin/isolation.rb +4 -3
- data/lib/rigor/protection/discovery_seed.rb +9 -36
- data/lib/rigor/protection/mutation_cache.rb +8 -2
- data/lib/rigor/rooted_glob.rb +27 -0
- data/lib/rigor/scope/discovery_index.rb +5 -0
- data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
- data/lib/rigor/scope/resolution_chain.rb +40 -5
- data/lib/rigor/scope.rb +45 -2
- data/lib/rigor/sig_gen/alias_index.rb +2 -1
- data/lib/rigor/sig_gen/classification.rb +4 -1
- data/lib/rigor/sig_gen/generator.rb +167 -22
- data/lib/rigor/sig_gen/layout_index.rb +5 -2
- data/lib/rigor/sig_gen/observation_collector.rb +137 -11
- data/lib/rigor/sig_gen/renderer.rb +13 -155
- data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
- data/lib/rigor/sig_gen/write_reporter.rb +203 -0
- data/lib/rigor/sig_gen/write_result.rb +3 -1
- data/lib/rigor/sig_gen/writer.rb +5 -3
- data/lib/rigor/signature_path_audit.rb +4 -2
- data/lib/rigor/type/constant.rb +10 -1
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
- data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
- data/sig/rigor/plugin/io_boundary.rbs +5 -2
- data/sig/rigor/rooted_glob.rbs +7 -0
- data/sig/rigor/scope.rbs +4 -3
- data/skills/rigor-doctor/references/01-checks.md +23 -11
- data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
- metadata +13 -1
|
@@ -5,7 +5,9 @@ Validates RSpec `let` / `subject` declarations within each
|
|
|
5
5
|
checks it ships have the lowest false-positive risk of the
|
|
6
6
|
proposed RSpec surface, run in pure syntactic-walk mode, and
|
|
7
7
|
catch real bugs that `rspec` / `rubocop-rspec` don't always
|
|
8
|
-
surface clearly.
|
|
8
|
+
surface clearly. It also narrows a local through `expect(x).to`
|
|
9
|
+
matchers, described under [Matcher narrowing](#matcher-narrowing).
|
|
10
|
+
No RSpec runtime dependency.
|
|
9
11
|
|
|
10
12
|
It ships bundled in `rigortype`. Activate it under `plugins:`:
|
|
11
13
|
|
|
@@ -50,6 +52,104 @@ The walker recognises `RSpec.describe … do` (root), nested
|
|
|
50
52
|
`describe` / `context … do`, `let(:name)` / `let!(:name)`, and
|
|
51
53
|
`subject(:name)` / bare `subject` (the implicit `:subject`).
|
|
52
54
|
|
|
55
|
+
## Matcher narrowing
|
|
56
|
+
|
|
57
|
+
`expect(x).to MATCHER` narrows the local `x` for the rest of the
|
|
58
|
+
example body, so downstream calls resolve against the matched type.
|
|
59
|
+
Only a plain local is a target.
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
RSpec.describe "Counter" do
|
|
63
|
+
it "narrows through the matcher" do
|
|
64
|
+
count = rand(10) if rand(2).zero? # count is Integer?
|
|
65
|
+
expect(count).to eq(42) # count is 42 from here on
|
|
66
|
+
count + 1 # no possible-nil-receiver warning
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| Matcher | Effect on `x` |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `eq(literal)` / `eql(literal)` | meet with `Constant<literal>` (see below) |
|
|
74
|
+
| `be_a(T)` / `be_kind_of(T)` / `be_instance_of(T)` / `be_an_instance_of(T)` | meet with `T` |
|
|
75
|
+
| `be_nil` | narrow to `Constant<nil>` |
|
|
76
|
+
| `match(/regex/)` | narrow to `String` |
|
|
77
|
+
| `not_to be_nil` / `to_not be_nil` | narrow away from `nil` |
|
|
78
|
+
| `not_to be_a(T)` / `not_to be_kind_of(T)` / `not_to be_instance_of(T)` / `not_to be_an_instance_of(T)` | narrow away from `T` |
|
|
79
|
+
| `not_to eq(literal)` / `not_to eql(literal)` (and `to_not`) | unchanged (see below) |
|
|
80
|
+
|
|
81
|
+
Other matchers emit nothing. `be_truthy` is not narrowed, nor is
|
|
82
|
+
`expect { ... }.to raise_error(...)` (the block form), nor an
|
|
83
|
+
`expect(foo.bar)` whose argument is not a local.
|
|
84
|
+
|
|
85
|
+
### Equality is a meet, not a replacement
|
|
86
|
+
|
|
87
|
+
`eq(literal)` passes when `x == literal`, and `eql(literal)` when
|
|
88
|
+
`x.eql?(literal)`. The local is the receiver, so `x`'s own operator
|
|
89
|
+
decides, and that operator can hold across classes: `1 == 1.0`, and a
|
|
90
|
+
class with its own `==` (a `ModInt` that compares `to_i`) passes
|
|
91
|
+
`expect(m).to eq(9)`. The matcher therefore never replaces `x`'s
|
|
92
|
+
type. It narrows each member of `x`'s type on its own.
|
|
93
|
+
|
|
94
|
+
The core equality classes are the nine in `EQUALITY_CORE_CLASSES`
|
|
95
|
+
(`plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb`):
|
|
96
|
+
`Integer`, `Float`, `Rational`, `Complex`, `String`, `Symbol`,
|
|
97
|
+
`NilClass`, `TrueClass`, and `FalseClass`. The first four are the
|
|
98
|
+
numeric classes.
|
|
99
|
+
|
|
100
|
+
- a member of the literal's own class narrows to the literal,
|
|
101
|
+
refinements of that class included. After `x = rand(10)`,
|
|
102
|
+
`expect(x).to eq(9)` gives `9`; `Integer?` with `eq(9)` gives `9`,
|
|
103
|
+
because the `nil` member cannot equal `9`. A refinement such as
|
|
104
|
+
`decimal-int-string?` (from `y = x && x.to_s` with `x` an
|
|
105
|
+
`Integer?`) gives `"3"` under `eq("3")`. A `Constant` member is kept
|
|
106
|
+
when it equals the literal and dropped otherwise;
|
|
107
|
+
- a `Float` member meets `0.0` and is kept, since `-0.0` also equals
|
|
108
|
+
it;
|
|
109
|
+
- a numeric member of another class (`Integer`, `Float`, `Rational`,
|
|
110
|
+
`Complex`) is kept under `eq`: `Integer | Float` with `eq(1)` gives
|
|
111
|
+
`1 | Float`;
|
|
112
|
+
- any other member of a core equality class is dropped:
|
|
113
|
+
`String | Integer` with `eq(9)` gives `9`;
|
|
114
|
+
- `eql(literal)` takes the same path, except that a numeric member of
|
|
115
|
+
another class is dropped, because `eql?` compares classes:
|
|
116
|
+
`Integer | Float` with `eql(1)` gives `1`.
|
|
117
|
+
|
|
118
|
+
### A member, or the whole local, keeps its type in these cases
|
|
119
|
+
|
|
120
|
+
A member outside the core equality classes makes the whole fact
|
|
121
|
+
unavailable, so the local keeps its type, not just that member:
|
|
122
|
+
|
|
123
|
+
- a member is `Dynamic`, or of a class outside `EQUALITY_CORE_CLASSES`,
|
|
124
|
+
such as a user class (`ModInt`), `Numeric`, or a generic `Array` or
|
|
125
|
+
`Hash`. Under `eq(9)` the whole local stays unchanged, so `9 | [1]`
|
|
126
|
+
and `9 | { a: 1 }` do not narrow;
|
|
127
|
+
- the project defines the operator (`==` for `eq`, `eql?` for `eql`)
|
|
128
|
+
on the literal's class, in any analysed file. A `def` or a
|
|
129
|
+
`define_method` both count. A core `==` can call back into the
|
|
130
|
+
literal's own `==` (`Integer#==` for a non-numeric argument,
|
|
131
|
+
`String#==` for one that responds to `to_str`), so the plugin does
|
|
132
|
+
not narrow there even when a kept member would survive;
|
|
133
|
+
- the project reopens a core ancestor of the member's class and
|
|
134
|
+
defines the operator there, such as `class Numeric; def eql?` or
|
|
135
|
+
`class Object; def ==`. Under `eql(9)` an `Integer` stays
|
|
136
|
+
`Integer`; under `eq(9)` the `nil` member of `Integer?` stays `nil`
|
|
137
|
+
while the `Integer` member narrows (`9?`);
|
|
138
|
+
- the literal is `nil`, `true`, or a Symbol, and a member is a user
|
|
139
|
+
class or `Dynamic`: the local's own `==` can match these literals
|
|
140
|
+
too, so the local keeps its type as it does for `9`;
|
|
141
|
+
- the local is unbound, no scope is available, or no member survives
|
|
142
|
+
the match (`nil` with `eq(9)`).
|
|
143
|
+
|
|
144
|
+
A reopening that defines only other methods, such as
|
|
145
|
+
`class String; def shout`, does not count: `String?` with
|
|
146
|
+
`eq("a")` still narrows to `"a"`. A `class Symbol; def ==` in the
|
|
147
|
+
project keeps the `Symbol` member and narrows the rest:
|
|
148
|
+
`Symbol | Integer | nil` with `eq(9)` gives `9 | Symbol`.
|
|
149
|
+
|
|
150
|
+
`not_to eq(literal)`, `to_not eq(literal)`, and their `eql` forms do
|
|
151
|
+
not narrow: `x` keeps the type it had before the matcher.
|
|
152
|
+
|
|
53
153
|
## Configuration
|
|
54
154
|
|
|
55
155
|
No configuration knobs. The plugin walks every file on the
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../../inference/narrowing"
|
|
6
|
+
require_relative "../../environment/member_consistency"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Analysis
|
|
10
|
+
module CheckRules
|
|
11
|
+
# Issue #1485 — whether a `case` clause the engine reads as exhausted by the earlier clauses (`:prior_exhaustion`
|
|
12
|
+
# in {UnreachableClauseCollector}) is dead whatever the subject holds. The engine's `Bot` says only that the
|
|
13
|
+
# earlier clauses covered every member of the subject's type, and a type Rigor inferred or read from a signature
|
|
14
|
+
# may be narrower than what the program holds: `STDOUT` is typed `IO`, a test passes a `StringIO`, which is not an
|
|
15
|
+
# `IO`, and `case io when IO … when StringIO` runs the second arm.
|
|
16
|
+
#
|
|
17
|
+
# So the verdict stands when the subject entering the `case` is made only of carriers that record what the file
|
|
18
|
+
# literally shows (`x = 1; case x when Integer … when String`), or when every class the clause names is at or
|
|
19
|
+
# below a class an earlier clause names (`when IO … when File`, `when Enumerable … when Array`, a repeated
|
|
20
|
+
# `when IO`): any object the clause could match then matches the earlier clause first. The ancestry is RBS's, or
|
|
21
|
+
# the project's own superclass and mixin edges, never past a class whose RBS ancestry Ruby contradicts
|
|
22
|
+
# (`Tempfile`, `< File` in RBS and `< Delegator` in Ruby). A clause that is not a class test (`in [a]`) names no
|
|
23
|
+
# class, so on a non-literal subject it is never proven.
|
|
24
|
+
#
|
|
25
|
+
# The proof assumes `when Base` matches every instance of a class below `Base`, as `Module#===` does. A class that
|
|
26
|
+
# defines its own `self.===` could break that; no such case has been observed.
|
|
27
|
+
class ClauseCoverage
|
|
28
|
+
# The carriers issue #1429 counts as literal, plus the value-pinned `nil`, `true` and `false`, whose `Nominal`
|
|
29
|
+
# is the single value itself.
|
|
30
|
+
LITERAL_CARRIERS = [Type::Constant, Type::Tuple, Type::HashShape, Type::Singleton].freeze
|
|
31
|
+
VALUE_PINNED_CLASSES = %w[NilClass TrueClass FalseClass].freeze
|
|
32
|
+
|
|
33
|
+
UNRELIABLE_ANCESTRY = Environment::MemberConsistency::RbsProof::UNRELIABLE_ANCESTRY
|
|
34
|
+
|
|
35
|
+
# @param scope — the `case` node's scope, which resolves the clauses' class constants.
|
|
36
|
+
# @param subject_type — the subject's type entering the `case`.
|
|
37
|
+
def initialize(scope, subject_type)
|
|
38
|
+
@scope = scope
|
|
39
|
+
@literal_subject = literal?(subject_type)
|
|
40
|
+
@earlier = []
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Adds the classes `clause` matches by class alone to those a later clause may be covered by: each class
|
|
44
|
+
# condition of a `when` (`when IO, nil` still matches every `IO` first), and the bare class pattern of an `in`.
|
|
45
|
+
def record(clause)
|
|
46
|
+
return if @scope.nil?
|
|
47
|
+
|
|
48
|
+
parts = clause.is_a?(Prism::WhenNode) ? clause.conditions : (pattern_alternatives(clause.pattern) || [])
|
|
49
|
+
@earlier.concat(parts.filter_map { |part| class_name(part) })
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# True when the `:prior_exhaustion` verdict on `clause` may stand.
|
|
53
|
+
def proven?(clause)
|
|
54
|
+
return true if @literal_subject
|
|
55
|
+
return false if @scope.nil?
|
|
56
|
+
|
|
57
|
+
names = clause_class_names(clause)
|
|
58
|
+
return false if names.nil?
|
|
59
|
+
|
|
60
|
+
names.all? do |name|
|
|
61
|
+
ancestors = ancestor_names(name)
|
|
62
|
+
!ancestors.nil? && @earlier.any? { |earlier| ancestors.any? { |ancestor| covered_by?(ancestor, earlier) } }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def literal?(type)
|
|
69
|
+
members = type.is_a?(Type::Union) ? type.members : [type]
|
|
70
|
+
members.all? do |member|
|
|
71
|
+
LITERAL_CARRIERS.any? { |carrier| member.is_a?(carrier) } ||
|
|
72
|
+
(member.is_a?(Type::Nominal) && VALUE_PINNED_CLASSES.include?(member.class_name))
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The class names `clause` matches by class alone, or nil when any part of it is something else: every
|
|
77
|
+
# condition of a `when`, and the bare class pattern of an `in` (`in C`, `in C => x`, `in A | B`).
|
|
78
|
+
def clause_class_names(clause)
|
|
79
|
+
parts = clause.is_a?(Prism::WhenNode) ? clause.conditions : pattern_alternatives(clause.pattern)
|
|
80
|
+
return nil if parts.nil? || parts.empty?
|
|
81
|
+
|
|
82
|
+
names = parts.map { |part| class_name(part) }
|
|
83
|
+
names.include?(nil) ? nil : names
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def class_name(node)
|
|
87
|
+
return nil unless node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
|
|
88
|
+
|
|
89
|
+
Inference::Narrowing.lexical_class_name(node, @scope)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def pattern_alternatives(pattern)
|
|
93
|
+
case pattern
|
|
94
|
+
when Prism::CapturePatternNode then pattern_alternatives(pattern.value)
|
|
95
|
+
when Prism::AlternationPatternNode
|
|
96
|
+
left = pattern_alternatives(pattern.left)
|
|
97
|
+
right = pattern_alternatives(pattern.right)
|
|
98
|
+
left && right && (left + right)
|
|
99
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode then [pattern]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# `name` and the ancestors the project gives it: the project classes and modules on its chain, and the first
|
|
104
|
+
# candidate the environment knows of each ancestor the project does not declare (`< StandardError`,
|
|
105
|
+
# `include Enumerable`). Nil when the chain was cut, so no coverage rests on part of it.
|
|
106
|
+
def ancestor_names(name)
|
|
107
|
+
return [name] unless @scope.known_user_class?(name)
|
|
108
|
+
|
|
109
|
+
external = @scope.external_ancestor_name_candidates(name).map do |candidates|
|
|
110
|
+
candidates.find { |candidate| Reflection.class_known?(candidate, scope: @scope) }
|
|
111
|
+
end
|
|
112
|
+
chain = Scope::ResolutionChain.for(@scope, name, :instance, :methods)
|
|
113
|
+
return nil if chain.truncated?
|
|
114
|
+
|
|
115
|
+
([name] + chain.entries.reject(&:external?).map(&:name) + external.compact).uniq
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# True when every instance of `name` is an instance of `earlier` by the environment's class ordering, past no
|
|
119
|
+
# class whose RBS ancestry Ruby contradicts.
|
|
120
|
+
def covered_by?(name, earlier)
|
|
121
|
+
environment = @scope.environment
|
|
122
|
+
case environment.class_ordering(name, earlier)
|
|
123
|
+
when :equal then true
|
|
124
|
+
when :subclass
|
|
125
|
+
UNRELIABLE_ANCESTRY.none? do |unreliable|
|
|
126
|
+
unreliable != earlier && %i[equal subclass].include?(environment.class_ordering(name, unreliable))
|
|
127
|
+
end
|
|
128
|
+
else false
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -17,6 +17,13 @@ module Rigor
|
|
|
17
17
|
# - **Singleton defs on locals.** `def o.m` defines `m` on the one object `o` holds. The local's type
|
|
18
18
|
# is not changed; `o.m` is simply not reported within the scope the `def` is written in (the
|
|
19
19
|
# enclosing `def`, `class` / `module` body, or file — a block shares its enclosing scope's locals).
|
|
20
|
+
# Issue #1722 — the other spellings that give that one object methods make the local opaque for every
|
|
21
|
+
# name in the same scope: `o.extend(M)`, `o.define_singleton_method(...)` (only the literal name it
|
|
22
|
+
# defines, when it names one), any call on `o.singleton_class` (`class_eval { def m }`, `define_method`,
|
|
23
|
+
# `include`), `class << o`, and an `o.instance_eval` / `o.instance_exec` that is given a String or whose
|
|
24
|
+
# block writes a `def` or calls `extend` / `define_singleton_method`. Where in the scope the call sits,
|
|
25
|
+
# and whether it runs, are not read: a call under an `if` may run. A local assigned `o.clone` shares
|
|
26
|
+
# what `o` has (`clone` copies the singleton class, `dup` does not).
|
|
20
27
|
#
|
|
21
28
|
# Built lazily: the walk runs the first time a would-fire call asks, so a file with no such call pays
|
|
22
29
|
# one small object and nothing else.
|
|
@@ -34,22 +41,45 @@ module Rigor
|
|
|
34
41
|
|
|
35
42
|
def refinements = (@refinements ||= Inference::InEffectRefinements.new(@root))
|
|
36
43
|
|
|
37
|
-
# Does a `def <local>.<name>` for this call's local receiver and method name
|
|
38
|
-
# is in?
|
|
44
|
+
# Does a `def <local>.<name>` for this call's local receiver and method name — or a call that gives the
|
|
45
|
+
# local's object methods Rigor does not enumerate (issue #1722) — sit in the scope the call is in?
|
|
39
46
|
def singleton_local_def?(call_node)
|
|
40
47
|
receiver = call_node.receiver
|
|
41
48
|
return false unless receiver.is_a?(Prism::LocalVariableReadNode)
|
|
42
49
|
|
|
43
50
|
build
|
|
44
|
-
|
|
45
|
-
@singleton_defs.any? do |local, method_name, start, stop|
|
|
46
|
-
local == receiver.name && method_name == call_node.name && offset >= start && offset < stop &&
|
|
47
|
-
!nested_scope_holds?(offset, start, stop)
|
|
48
|
-
end
|
|
51
|
+
singleton_local_holds?(receiver.name, call_node.name, call_node.location.start_offset, CLONE_DEPTH)
|
|
49
52
|
end
|
|
50
53
|
|
|
54
|
+
# The name an opening records when it may define any method on the local's object.
|
|
55
|
+
ANY_METHOD = Object.new.freeze
|
|
56
|
+
# How many `t = s.clone` links a lookup follows.
|
|
57
|
+
CLONE_DEPTH = 4
|
|
58
|
+
EVAL_CALLS = %i[instance_eval instance_exec].freeze
|
|
59
|
+
EVAL_BLOCK_CALLS = %i[extend define_singleton_method].freeze
|
|
60
|
+
private_constant :ANY_METHOD, :CLONE_DEPTH, :EVAL_CALLS, :EVAL_BLOCK_CALLS
|
|
61
|
+
|
|
51
62
|
private
|
|
52
63
|
|
|
64
|
+
def singleton_local_holds?(local_name, method_name, offset, depth)
|
|
65
|
+
opened = in_scope?(@singleton_defs, offset) do |local, name|
|
|
66
|
+
local == local_name && (name.equal?(ANY_METHOD) || name == method_name)
|
|
67
|
+
end
|
|
68
|
+
return true if opened
|
|
69
|
+
return false if depth.zero?
|
|
70
|
+
|
|
71
|
+
@clones.any? do |target, source, start, stop|
|
|
72
|
+
target == local_name && offset >= start && offset < stop && !nested_scope_holds?(offset, start, stop) &&
|
|
73
|
+
singleton_local_holds?(source, method_name, offset, depth - 1)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def in_scope?(rows, offset)
|
|
78
|
+
rows.any? do |local, name, start, stop|
|
|
79
|
+
yield(local, name) && offset >= start && offset < stop && !nested_scope_holds?(offset, start, stop)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
53
83
|
# Is `offset` inside a local scope nested in the one `[start, stop)` spans? A `def` in the file's top level
|
|
54
84
|
# does not see the top level's `o`.
|
|
55
85
|
def nested_scope_holds?(offset, start, stop)
|
|
@@ -64,6 +94,7 @@ module Rigor
|
|
|
64
94
|
|
|
65
95
|
@built = true
|
|
66
96
|
@singleton_defs = []
|
|
97
|
+
@clones = []
|
|
67
98
|
@scopes = []
|
|
68
99
|
return if @root.nil?
|
|
69
100
|
|
|
@@ -78,11 +109,16 @@ module Rigor
|
|
|
78
109
|
when Prism::ClassNode, Prism::ModuleNode
|
|
79
110
|
return walk_children(node.body, scope_span(node))
|
|
80
111
|
when Prism::SingletonClassNode
|
|
112
|
+
record_opened_local(node.expression, ANY_METHOD, locals)
|
|
81
113
|
walk(node.expression, locals)
|
|
82
114
|
return walk_children(node.body, scope_span(node))
|
|
83
115
|
when Prism::DefNode
|
|
84
116
|
record_singleton_def(node, locals)
|
|
85
117
|
return walk_children(node.body, scope_span(node))
|
|
118
|
+
when Prism::CallNode
|
|
119
|
+
record_singleton_opening(node, locals)
|
|
120
|
+
when Prism::LocalVariableWriteNode
|
|
121
|
+
record_clone(node, locals)
|
|
86
122
|
end
|
|
87
123
|
|
|
88
124
|
walk_children(node, locals)
|
|
@@ -101,6 +137,74 @@ module Rigor
|
|
|
101
137
|
@singleton_defs << [receiver.name, node.name, *locals]
|
|
102
138
|
end
|
|
103
139
|
|
|
140
|
+
# Issue #1722 — a call that gives the object its receiver local holds singleton methods.
|
|
141
|
+
def record_singleton_opening(node, locals)
|
|
142
|
+
receiver = node.receiver
|
|
143
|
+
if singleton_class_of_local?(receiver)
|
|
144
|
+
record_opened_local(receiver.receiver, ANY_METHOD, locals)
|
|
145
|
+
elsif receiver.is_a?(Prism::LocalVariableReadNode)
|
|
146
|
+
name = opened_method_name(node)
|
|
147
|
+
record_opened_local(receiver, name, locals) if name
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# `o.singleton_class`.
|
|
152
|
+
def singleton_class_of_local?(node)
|
|
153
|
+
node.is_a?(Prism::CallNode) && node.name == :singleton_class && node.arguments.nil? &&
|
|
154
|
+
node.receiver.is_a?(Prism::LocalVariableReadNode)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The method name a call on a local defines on the local's object: the literal name given to
|
|
158
|
+
# `define_singleton_method`, {ANY_METHOD} where it may be any name, or nil when the call defines none.
|
|
159
|
+
def opened_method_name(node)
|
|
160
|
+
case node.name
|
|
161
|
+
when :extend then ANY_METHOD
|
|
162
|
+
when :define_singleton_method then literal_name(node.arguments&.arguments&.first) || ANY_METHOD
|
|
163
|
+
when *EVAL_CALLS then ANY_METHOD if eval_defines?(node)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def literal_name(node)
|
|
168
|
+
node.unescaped.to_sym if node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# An `instance_eval` given code as a String, or whose block writes a `def` or calls `extend` /
|
|
172
|
+
# `define_singleton_method` on its `self`.
|
|
173
|
+
def eval_defines?(node)
|
|
174
|
+
return true unless node.arguments.nil?
|
|
175
|
+
|
|
176
|
+
block = node.block
|
|
177
|
+
block.is_a?(Prism::BlockNode) && defines_on_self?(block.body)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def defines_on_self?(node)
|
|
181
|
+
return false if node.nil?
|
|
182
|
+
return true if node.is_a?(Prism::DefNode)
|
|
183
|
+
if node.is_a?(Prism::CallNode) && EVAL_BLOCK_CALLS.include?(node.name) &&
|
|
184
|
+
(node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode))
|
|
185
|
+
return true
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
found = false
|
|
189
|
+
node.rigor_each_child { |child| found ||= defines_on_self?(child) }
|
|
190
|
+
found
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def record_opened_local(node, method_name, locals)
|
|
194
|
+
return unless node.is_a?(Prism::LocalVariableReadNode)
|
|
195
|
+
|
|
196
|
+
@singleton_defs << [node.name, method_name, *locals]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# `t = s.clone`: `clone` copies the singleton class, so `t` holds what `s` was given.
|
|
200
|
+
def record_clone(node, locals)
|
|
201
|
+
value = node.value
|
|
202
|
+
return unless value.is_a?(Prism::CallNode) && value.name == :clone
|
|
203
|
+
return unless value.receiver.is_a?(Prism::LocalVariableReadNode)
|
|
204
|
+
|
|
205
|
+
@clones << [node.name, value.receiver.name, *locals]
|
|
206
|
+
end
|
|
207
|
+
|
|
104
208
|
# The span of a node that opens a local scope, recorded for {#nested_scope_holds?}.
|
|
105
209
|
def scope_span(node)
|
|
106
210
|
span = span_of(node)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../reflection"
|
|
4
|
+
require_relative "../dependency_recorder"
|
|
5
|
+
require_relative "../../inference/singleton_hook_decline"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Analysis
|
|
9
|
+
module CheckRules
|
|
10
|
+
# Issue #1722 — what the project may give an instance of an RBS-declared class beyond the class's RBS, asked
|
|
11
|
+
# by `call.undefined-method` once the RBS lookup and the project's own tables for the class itself have come
|
|
12
|
+
# back "absent". Three places the earlier probes do not look:
|
|
13
|
+
#
|
|
14
|
+
# - **A `method_missing` on an RBS ancestor.** `class Integer; def method_missing(*)` (or one on `Numeric`,
|
|
15
|
+
# `Comparable`, `Object`, `Kernel`, `BasicObject`, or a project module mixed into any of them) answers
|
|
16
|
+
# every name Ruby dispatches to an Integer. `respond_to_missing?` is not read: Ruby dispatches to
|
|
17
|
+
# `method_missing` whether or not it is defined.
|
|
18
|
+
# - **A project mixin of the class or an RBS ancestor.** `Integer.include(Ext)` in another file, or
|
|
19
|
+
# `Comparable.include(Ext)` / `module Comparable; def zzz`, gives every Integer `zzz`. The earlier probes
|
|
20
|
+
# read the class's own chain through `discovered_methods`, which holds no plain `def` written in another
|
|
21
|
+
# file than the one being checked; this reads the def table too for a project module, and walks the
|
|
22
|
+
# ancestors RBS lists after the class. A cross-file `def` on an RBS-declared class or module itself stays
|
|
23
|
+
# ADR-17's monkey-patch report ({.mixin_defines?}). A mixin with an open surface — a project module with a
|
|
24
|
+
# recorded `included` / `extended` / … hook, a `"*"` mixin or `ActiveSupport::Concern`, or one neither RBS nor
|
|
25
|
+
# the project declares — answers any name, except on `Object`, `Kernel` and `BasicObject`: there only a
|
|
26
|
+
# module that defines the name answers, #1697's reading (`Inference::ObjectMixins`) of a module mixed into
|
|
27
|
+
# every object.
|
|
28
|
+
# - **A project subclass** ({.subclass_may_define?}). A value typed by its class's name through a signature
|
|
29
|
+
# (`#: () -> String`) may be an instance of any subclass, so a method a project subclass defines may be
|
|
30
|
+
# the one that runs.
|
|
31
|
+
#
|
|
32
|
+
# Each ancestor's name is filed as an ADR-46 class dependency, so a file that later reopens one re-checks
|
|
33
|
+
# the call, and the subclass walk files a `subclass_of` edge per class it reads.
|
|
34
|
+
module RbsReceiverSurface
|
|
35
|
+
MISSING_HOOK = :method_missing
|
|
36
|
+
# The roots whose project mixins `Inference::ObjectMixins` answers for.
|
|
37
|
+
OBJECT_ROOTS = %w[Object Kernel BasicObject].to_set.freeze
|
|
38
|
+
private_constant :MISSING_HOOK, :OBJECT_ROOTS
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
# Whether the project may answer `method_name` on an instance of `class_name` through one of its RBS
|
|
43
|
+
# ancestors.
|
|
44
|
+
def ancestor_may_answer?(scope, class_name, method_name)
|
|
45
|
+
ancestor_names(scope, class_name).any? do |name|
|
|
46
|
+
DependencyRecorder.read_last_segment(:class, name) if DependencyRecorder.active?
|
|
47
|
+
next false unless scope.known_user_class?(name)
|
|
48
|
+
|
|
49
|
+
chain_answers?(scope, name, method_name, open_mixins: !OBJECT_ROOTS.include?(name))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Whether the singleton side of `class_name` may reach a project `method_missing`: a `def self.method_missing`
|
|
54
|
+
# on the class or an RBS superclass, or an instance `method_missing` on `Class`, `Module`, `Object`,
|
|
55
|
+
# `Kernel` or `BasicObject` (a class object is an instance of each).
|
|
56
|
+
def singleton_hook?(scope, class_name)
|
|
57
|
+
loader = scope.environment&.rbs_loader
|
|
58
|
+
pairs = loader ? loader.singleton_ancestors_for(class_name.to_s) : []
|
|
59
|
+
pairs = [[:singleton, class_name.to_s]] if pairs.empty?
|
|
60
|
+
pairs.any? do |kind, name|
|
|
61
|
+
DependencyRecorder.read_last_segment(:class, name) if DependencyRecorder.active?
|
|
62
|
+
if kind == :singleton
|
|
63
|
+
!scope.singleton_def_for(name, MISSING_HOOK).nil? ||
|
|
64
|
+
scope.discovered_method?(name, MISSING_HOOK, :singleton)
|
|
65
|
+
else
|
|
66
|
+
scope.known_user_class?(name) && hook_on_chain?(scope, name)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Whether a project subclass of `class_name`, at any depth, defines `method_name` (through its own
|
|
72
|
+
# ancestry, which reaches its project mixins) or a `method_missing`. Its parent is read as written and
|
|
73
|
+
# resolved the way the class header resolves it: the first project class the spelling can denote, else the
|
|
74
|
+
# top-level name, which is the RBS class.
|
|
75
|
+
def subclass_may_define?(scope, class_name, method_name)
|
|
76
|
+
each_subclass(scope, class_name.to_s) do |subclass|
|
|
77
|
+
return true if project_defines?(scope, subclass, method_name) ||
|
|
78
|
+
project_defines?(scope, subclass, MISSING_HOOK) ||
|
|
79
|
+
!scope.user_def_through_ancestors(subclass, method_name).first.nil? ||
|
|
80
|
+
scope.discovered_method_through_ancestors?(subclass, method_name, :instance)
|
|
81
|
+
end
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def ancestor_names(scope, class_name)
|
|
86
|
+
loader = scope.environment&.rbs_loader
|
|
87
|
+
names = loader ? loader.ancestor_names_for(class_name.to_s) : []
|
|
88
|
+
names.empty? ? [class_name.to_s] : names
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A search of `name`'s project chain for an entry that may answer: a project entry that defines
|
|
92
|
+
# `method_name` or a `method_missing`, or an external mixin RBS declares `method_name` on. When
|
|
93
|
+
# `open_mixins` (every ancestor but `Object`, `Kernel` and `BasicObject`, #1697), also a mixin whose surface
|
|
94
|
+
# is open: a project module SingletonHookDecline reads as hook-capable, or an external neither RBS nor the
|
|
95
|
+
# project declares. A chain cut at its budget may hold an answer past the cut.
|
|
96
|
+
def chain_answers?(scope, name, method_name, open_mixins:)
|
|
97
|
+
chain = Scope::ResolutionChain.for(scope, name, :instance, :methods)
|
|
98
|
+
found = chain.search(scope, side: :instance) do |entry|
|
|
99
|
+
if entry.external?
|
|
100
|
+
external_may_answer?(scope, entry, method_name, open_mixins)
|
|
101
|
+
else
|
|
102
|
+
project_defines?(scope, entry.name, MISSING_HOOK) ||
|
|
103
|
+
mixin_defines?(scope, entry, method_name, open_mixins)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
found || chain.truncated?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The singleton side's question of an instance chain: does a project entry on it define `method_missing`?
|
|
110
|
+
def hook_on_chain?(scope, name)
|
|
111
|
+
chain = Scope::ResolutionChain.for(scope, name, :instance, :methods)
|
|
112
|
+
found = chain.search(scope, side: :instance) do |entry|
|
|
113
|
+
!entry.external? && project_defines?(scope, entry.name, MISSING_HOOK)
|
|
114
|
+
end
|
|
115
|
+
found || chain.truncated?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def project_defines?(scope, owner, method_name)
|
|
119
|
+
!scope.user_def_for(owner, method_name).nil? || scope.discovered_method?(owner, method_name, :instance)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# ADR-17 — a `def` the project writes on a class or module RBS declares (`class String; def x`, `module
|
|
123
|
+
# Comparable; def x`) in another file than the call is a monkey-patch Rigor does not apply cross-file, and the
|
|
124
|
+
# rule reports it with the `pre_eval:` advice, so on such an entry only what `discovered_methods` carries
|
|
125
|
+
# answers. A project module's own `def` answers from any file.
|
|
126
|
+
#
|
|
127
|
+
# When `open_mixins`, a project module {Inference::SingletonHookDecline} reads as hook-capable — one that
|
|
128
|
+
# records an `included` / `extended` / … hook (`def self.included(base) = base.class_eval { def x }`), lists
|
|
129
|
+
# a `"*"` mixin, or extends `ActiveSupport::Concern` — may answer any name.
|
|
130
|
+
def mixin_defines?(scope, entry, method_name, open_mixins)
|
|
131
|
+
owner = entry.name
|
|
132
|
+
if Rigor::Reflection.rbs_class_known?(owner, scope: scope)
|
|
133
|
+
scope.discovered_method?(owner, method_name, :instance)
|
|
134
|
+
else
|
|
135
|
+
project_defines?(scope, owner, method_name) ||
|
|
136
|
+
(open_mixins && Inference::SingletonHookDecline.entry_hook_capable?(scope, entry))
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# An external the project declares (a module with no `def`, which the chain does not hold as a project entry)
|
|
141
|
+
# is known, and answers only through RBS.
|
|
142
|
+
def external_may_answer?(scope, entry, method_name, open_mixins)
|
|
143
|
+
candidates = entry.candidates
|
|
144
|
+
return open_mixins if candidates.empty?
|
|
145
|
+
return true if candidates.any? do |candidate|
|
|
146
|
+
Rigor::Reflection.instance_method_definition(candidate, method_name, scope: scope)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
open_mixins && candidates.none? { |candidate| declared?(scope, candidate) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def declared?(scope, name)
|
|
153
|
+
scope.discovered_classes.key?(name) || Rigor::Reflection.rbs_class_known?(name, scope: scope)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def each_subclass(scope, class_name)
|
|
157
|
+
children = children_by_parent(scope)
|
|
158
|
+
queue = children.fetch(class_name, []).dup
|
|
159
|
+
seen = {}
|
|
160
|
+
record_subclasses_read(class_name)
|
|
161
|
+
until queue.empty?
|
|
162
|
+
subclass = queue.shift
|
|
163
|
+
next if seen[subclass]
|
|
164
|
+
|
|
165
|
+
seen[subclass] = true
|
|
166
|
+
record_subclasses_read(subclass)
|
|
167
|
+
yield subclass
|
|
168
|
+
queue.concat(children.fetch(subclass, []))
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def children_by_parent(scope)
|
|
173
|
+
scope.discovered_superclasses.each_with_object({}) do |(child, raw_parent), children|
|
|
174
|
+
next if raw_parent.nil?
|
|
175
|
+
|
|
176
|
+
candidates = scope.ancestor_name_candidates(child, raw_parent.to_s)
|
|
177
|
+
parent = candidates.find { |name| scope.known_user_class?(name) } || candidates.last
|
|
178
|
+
(children[parent] ||= []) << child if parent
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def record_subclasses_read(class_name)
|
|
183
|
+
return unless DependencyRecorder.active?
|
|
184
|
+
|
|
185
|
+
DependencyRecorder.read_last_segment(:subclass_of, class_name)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -168,7 +168,9 @@ module Rigor
|
|
|
168
168
|
# Bare (dot-less) diagnostic ids the engine emits outside the catalogue (see the `rule:` literals in
|
|
169
169
|
# `Analysis::Runner` / `Runner::DiagnosticAggregator`). A token equal to one of these is treated as
|
|
170
170
|
# known by `suppression.unknown-rule` even though it carries no family prefix; extend the list when
|
|
171
|
-
# the runner grows a new bare id.
|
|
171
|
+
# the runner grows a new bare id. Ids a marker or `disable:` cannot suppress (the `--explain`
|
|
172
|
+
# `fallback` row, the `contribution_merge` conflict reasons) stay out: they are
|
|
173
|
+
# `ConfigAudit::SEVERITY_ONLY_IDS`, known to the `severity_overrides:` audit alone.
|
|
172
174
|
NON_CHECK_DIAGNOSTIC_IDS = %w[
|
|
173
175
|
configuration-error load-error pool-degraded runtime-error source-rbs-synthesis-failed
|
|
174
176
|
source-rbs-annotation-not-honoured
|