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.
Files changed (147) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +90 -17
  4. data/docs/manual/02-cli-reference.md +39 -9
  5. data/docs/manual/03-configuration.md +10 -8
  6. data/docs/manual/04-diagnostics.md +16 -5
  7. data/docs/manual/05-inspecting-types.md +2 -2
  8. data/docs/manual/06-baseline.md +1 -1
  9. data/docs/manual/07-plugins.md +2 -1
  10. data/docs/manual/08-skills.md +7 -0
  11. data/docs/manual/10-mcp-server.md +8 -1
  12. data/docs/manual/18-removing-dead-code.md +18 -7
  13. data/docs/manual/plugins/rigor-rspec.md +101 -1
  14. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  15. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  16. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  18. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  19. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  20. data/lib/rigor/analysis/check_rules.rb +228 -15
  21. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  22. data/lib/rigor/analysis/incremental.rb +49 -0
  23. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  24. data/lib/rigor/analysis/incremental_session.rb +166 -24
  25. data/lib/rigor/analysis/path_expansion.rb +3 -1
  26. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  27. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  28. data/lib/rigor/analysis/rule_catalog.rb +17 -4
  29. data/lib/rigor/analysis/runner/pool_coordinator.rb +69 -16
  30. data/lib/rigor/analysis/runner.rb +69 -65
  31. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  32. data/lib/rigor/analysis/worker_session.rb +12 -1
  33. data/lib/rigor/cache/descriptor.rb +40 -7
  34. data/lib/rigor/cache/engine_source.rb +3 -2
  35. data/lib/rigor/cache/incremental_snapshot.rb +26 -8
  36. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  37. data/lib/rigor/cache/store.rb +4 -3
  38. data/lib/rigor/cli/command.rb +3 -1
  39. data/lib/rigor/cli/docs_command.rb +3 -2
  40. data/lib/rigor/cli/doctor_command.rb +1 -1
  41. data/lib/rigor/cli/explain_command.rb +3 -2
  42. data/lib/rigor/cli/plugin_command.rb +2 -1
  43. data/lib/rigor/cli/plugins_command.rb +2 -1
  44. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  45. data/lib/rigor/cli/skill_command.rb +2 -1
  46. data/lib/rigor/cli/skill_describe.rb +3 -2
  47. data/lib/rigor/cli/unused_command.rb +10 -7
  48. data/lib/rigor/cli.rb +3 -2
  49. data/lib/rigor/config_audit.rb +60 -15
  50. data/lib/rigor/configuration/severity_profile.rb +20 -2
  51. data/lib/rigor/configuration.rb +29 -3
  52. data/lib/rigor/effects/signature_sources.rb +3 -1
  53. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  54. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  55. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  56. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  58. data/lib/rigor/environment/rbs_loader.rb +21 -1
  59. data/lib/rigor/inference/block_call_timing.rb +7 -1
  60. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  61. data/lib/rigor/inference/captured_locals.rb +44 -13
  62. data/lib/rigor/inference/clause_widening.rb +90 -0
  63. data/lib/rigor/inference/definer_resolution.rb +87 -20
  64. data/lib/rigor/inference/expression_typer.rb +35 -5
  65. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  66. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  68. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  69. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  70. data/lib/rigor/inference/module_function_state.rb +137 -42
  71. data/lib/rigor/inference/narrowing.rb +54 -10
  72. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  73. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  74. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  75. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  76. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  77. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  78. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  79. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  80. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  81. data/lib/rigor/inference/return_barrier.rb +16 -0
  82. data/lib/rigor/inference/scope_indexer.rb +675 -105
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +11 -6
  84. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  85. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  86. data/lib/rigor/inference/statement_evaluator.rb +446 -139
  87. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  88. data/lib/rigor/lens/contract_reader.rb +45 -2
  89. data/lib/rigor/lens/document.rb +2 -1
  90. data/lib/rigor/lens/file_reader.rb +12 -2
  91. data/lib/rigor/lens/members.rb +16 -2
  92. data/lib/rigor/lens/run.rb +34 -7
  93. data/lib/rigor/mcp/server.rb +2 -2
  94. data/lib/rigor/plugin/base.rb +3 -2
  95. data/lib/rigor/plugin/io_boundary.rb +42 -6
  96. data/lib/rigor/plugin/isolation.rb +4 -3
  97. data/lib/rigor/protection/discovery_seed.rb +9 -36
  98. data/lib/rigor/protection/mutation_cache.rb +8 -2
  99. data/lib/rigor/rooted_glob.rb +27 -0
  100. data/lib/rigor/scope/discovery_index.rb +5 -0
  101. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  102. data/lib/rigor/scope/resolution_chain.rb +40 -5
  103. data/lib/rigor/scope.rb +45 -2
  104. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  105. data/lib/rigor/sig_gen/classification.rb +4 -1
  106. data/lib/rigor/sig_gen/generator.rb +167 -22
  107. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  108. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  109. data/lib/rigor/sig_gen/renderer.rb +13 -155
  110. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  111. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  112. data/lib/rigor/sig_gen/write_result.rb +3 -1
  113. data/lib/rigor/sig_gen/writer.rb +5 -3
  114. data/lib/rigor/signature_path_audit.rb +4 -2
  115. data/lib/rigor/type/constant.rb +10 -1
  116. data/lib/rigor/version.rb +1 -1
  117. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  118. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  119. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  120. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  121. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  122. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  123. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  124. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  125. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  126. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  127. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  128. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  129. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  130. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  131. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  132. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  133. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  134. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  135. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  136. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  137. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  138. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  139. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  141. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  142. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  143. data/sig/rigor/rooted_glob.rbs +7 -0
  144. data/sig/rigor/scope.rbs +4 -3
  145. data/skills/rigor-doctor/references/01-checks.md +23 -11
  146. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  147. 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. No RSpec runtime dependency.
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 sit in the scope the call
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
- offset = call_node.location.start_offset
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