rigortype 0.4.1 → 0.4.2

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 (182) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/io.yml +1 -0
  4. data/data/builtins/ruby_core/proc.yml +4 -0
  5. data/data/builtins/ruby_core/random.yml +4 -0
  6. data/data/builtins/ruby_core/struct.yml +3 -0
  7. data/data/core_overlay/env.rbs +34 -0
  8. data/data/core_overlay/gc.rbs +50 -0
  9. data/data/core_overlay/hash.rbs +45 -0
  10. data/data/core_overlay/integer.rbs +31 -0
  11. data/data/core_overlay/io_buffer.rbs +27 -0
  12. data/data/core_overlay/kernel.rbs +40 -0
  13. data/data/core_overlay/match_data.rbs +28 -0
  14. data/data/core_overlay/module.rbs +51 -0
  15. data/data/core_overlay/object_space.rbs +29 -0
  16. data/data/core_overlay/proc.rbs +45 -0
  17. data/data/core_overlay/range.rbs +40 -0
  18. data/data/core_overlay/source_range.rbs +78 -0
  19. data/data/core_overlay/string.rbs +86 -0
  20. data/data/vendored_gem_sigs/prime/integer-extension.rbs +41 -0
  21. data/data/vendored_gem_sigs/prime/prime.rbs +372 -0
  22. data/docs/handbook/01-getting-started.md +3 -3
  23. data/docs/handbook/06-classes.md +3 -1
  24. data/docs/handbook/11-sig-gen.md +6 -0
  25. data/docs/handbook/appendix-liskov.md +5 -0
  26. data/docs/manual/03-configuration.md +1 -1
  27. data/docs/manual/04-diagnostics.md +2 -1
  28. data/docs/manual/06-baseline.md +2 -0
  29. data/docs/manual/07-plugins.md +1 -1
  30. data/docs/manual/18-removing-dead-code.md +32 -2
  31. data/docs/manual/plugins/README.md +6 -1
  32. data/docs/manual/plugins/rigor-activerecord.md +21 -0
  33. data/docs/manual/plugins/rigor-alba.md +60 -0
  34. data/docs/manual/plugins/rigor-minitest.md +22 -4
  35. data/docs/manual/plugins/rigor-typelizer.md +71 -0
  36. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +27 -3
  37. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +21 -92
  38. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +275 -0
  39. data/lib/rigor/analysis/check_rules/rule_ids.rb +4 -0
  40. data/lib/rigor/analysis/check_rules/source_arity.rb +21 -2
  41. data/lib/rigor/analysis/check_rules.rb +289 -57
  42. data/lib/rigor/analysis/dependency_recorder.rb +15 -0
  43. data/lib/rigor/analysis/dependency_source_inference/builder.rb +8 -1
  44. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +21 -0
  45. data/lib/rigor/analysis/dependency_source_inference/index.rb +36 -11
  46. data/lib/rigor/analysis/dependency_source_inference/walker.rb +196 -27
  47. data/lib/rigor/analysis/erb_template_detector.rb +2 -1
  48. data/lib/rigor/analysis/incremental.rb +29 -9
  49. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +30 -5
  50. data/lib/rigor/analysis/incremental_run_slot.rb +3 -1
  51. data/lib/rigor/analysis/incremental_session.rb +14 -1
  52. data/lib/rigor/analysis/reachability/graph.rb +123 -23
  53. data/lib/rigor/analysis/reachability/scan.rb +290 -36
  54. data/lib/rigor/analysis/rule_catalog.rb +43 -0
  55. data/lib/rigor/analysis/run_cache_key.rb +16 -1
  56. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +87 -5
  57. data/lib/rigor/analysis/runner/pool_coordinator.rb +50 -11
  58. data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
  59. data/lib/rigor/analysis/runner.rb +34 -3
  60. data/lib/rigor/analysis/worker_session.rb +7 -4
  61. data/lib/rigor/cache/incremental_snapshot.rb +22 -0
  62. data/lib/rigor/cli/annotate_command.rb +5 -0
  63. data/lib/rigor/cli/baseline_command.rb +7 -18
  64. data/lib/rigor/cli/check_command.rb +2 -1
  65. data/lib/rigor/cli/trace_command.rb +5 -0
  66. data/lib/rigor/cli/type_of_command.rb +5 -3
  67. data/lib/rigor/cli/unused_command.rb +13 -7
  68. data/lib/rigor/cli.rb +10 -2
  69. data/lib/rigor/configuration/severity_profile.rb +6 -0
  70. data/lib/rigor/configuration.rb +46 -2
  71. data/lib/rigor/environment/gated_signature_guard.rb +43 -0
  72. data/lib/rigor/environment/gated_signature_plan.rb +339 -0
  73. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  74. data/lib/rigor/environment/rbs_loader.rb +283 -14
  75. data/lib/rigor/environment/required_features.rb +206 -0
  76. data/lib/rigor/environment.rb +21 -3
  77. data/lib/rigor/inference/acceptance.rb +28 -3
  78. data/lib/rigor/inference/builtins/cruby_definers.rb +61 -0
  79. data/lib/rigor/inference/builtins/method_catalog.rb +3 -0
  80. data/lib/rigor/inference/def_node_resolver.rb +23 -1
  81. data/lib/rigor/inference/expression_typer.rb +215 -16
  82. data/lib/rigor/inference/global_write_census.rb +7 -4
  83. data/lib/rigor/inference/guard_rebinding.rb +1 -1
  84. data/lib/rigor/inference/in_effect_refinements/proc_literals.rb +83 -0
  85. data/lib/rigor/inference/in_effect_refinements/refine_defs.rb +54 -0
  86. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +282 -0
  87. data/lib/rigor/inference/in_effect_refinements/walk.rb +276 -0
  88. data/lib/rigor/inference/in_effect_refinements.rb +256 -0
  89. data/lib/rigor/inference/key_presence_guard/scan.rb +281 -0
  90. data/lib/rigor/inference/key_presence_guard.rb +303 -0
  91. data/lib/rigor/inference/macro_block_self_type.rb +59 -12
  92. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  93. data/lib/rigor/inference/method_dispatcher/arithmetic_sequence_elements.rb +73 -0
  94. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +68 -0
  95. data/lib/rigor/inference/method_dispatcher/integer_step_block_params.rb +81 -0
  96. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -0
  97. data/lib/rigor/inference/method_dispatcher/keyword_arguments.rb +267 -0
  98. data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -1
  99. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +122 -105
  100. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +259 -28
  101. data/lib/rigor/inference/method_dispatcher/splat_arity.rb +71 -0
  102. data/lib/rigor/inference/method_dispatcher.rb +44 -11
  103. data/lib/rigor/inference/module_function_state.rb +2 -1
  104. data/lib/rigor/inference/narrowing.rb +20 -6
  105. data/lib/rigor/inference/object_mixins.rb +180 -0
  106. data/lib/rigor/inference/project_patched_methods.rb +10 -5
  107. data/lib/rigor/inference/project_patched_scanner.rb +71 -3
  108. data/lib/rigor/inference/refine_census.rb +239 -0
  109. data/lib/rigor/inference/refine_self.rb +317 -0
  110. data/lib/rigor/inference/refined_dispatch/own_method.rb +403 -0
  111. data/lib/rigor/inference/refined_dispatch.rb +272 -0
  112. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +13 -0
  113. data/lib/rigor/inference/scope_indexer.rb +687 -60
  114. data/lib/rigor/inference/statement_evaluator.rb +71 -20
  115. data/lib/rigor/inference/string_mutation.rb +9 -3
  116. data/lib/rigor/inference/synthetic_method_scanner.rb +2 -1
  117. data/lib/rigor/inference/version_guard.rb +19 -10
  118. data/lib/rigor/language_server/project_context.rb +31 -0
  119. data/lib/rigor/language_server/server.rb +18 -0
  120. data/lib/rigor/plugin/inflector.rb +1 -1
  121. data/lib/rigor/plugin/io_boundary.rb +42 -3
  122. data/lib/rigor/plugin/macro/block_as_method.rb +51 -8
  123. data/lib/rigor/project_environment.rb +29 -6
  124. data/lib/rigor/protection/mutation_cache.rb +1 -1
  125. data/lib/rigor/protection/mutator.rb +1 -1
  126. data/lib/rigor/reflection/constant_ancestors.rb +127 -11
  127. data/lib/rigor/reflection/constant_path.rb +13 -2
  128. data/lib/rigor/reflection.rb +40 -8
  129. data/lib/rigor/scope/discovery_index.rb +40 -1
  130. data/lib/rigor/scope/resolution_chain.rb +159 -11
  131. data/lib/rigor/scope.rb +189 -10
  132. data/lib/rigor/sig_gen/classification.rb +4 -1
  133. data/lib/rigor/sig_gen/generator.rb +55 -5
  134. data/lib/rigor/sig_gen/method_candidate.rb +2 -2
  135. data/lib/rigor/sig_gen/skip_reason_catalog.rb +12 -0
  136. data/lib/rigor/source/alias_names.rb +52 -0
  137. data/lib/rigor/source/literals.rb +3 -2
  138. data/lib/rigor/source/symbol_name.rb +20 -0
  139. data/lib/rigor/type/combinator.rb +34 -2
  140. data/lib/rigor/type/float_range.rb +4 -1
  141. data/lib/rigor/type/nominal.rb +6 -1
  142. data/lib/rigor/version.rb +1 -1
  143. data/plugins/rigor-ac-library-rb/lib/rigor/plugin/ac_library_rb.rb +28 -0
  144. data/plugins/rigor-ac-library-rb/lib/rigor-ac-library-rb.rb +3 -0
  145. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/convolution.rbs +7 -0
  146. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/data_structures.rbs +160 -0
  147. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/graph.rbs +94 -0
  148. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/math.rbs +42 -0
  149. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/modint.rbs +71 -0
  150. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/object.rbs +37 -0
  151. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/segtree.rbs +83 -0
  152. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +2 -1
  153. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +100 -0
  154. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +94 -10
  155. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +43 -2
  156. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +3 -1
  157. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +149 -0
  158. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +74 -0
  159. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +104 -0
  160. data/plugins/rigor-alba/lib/rigor/plugin/alba.rb +85 -0
  161. data/plugins/rigor-alba/lib/rigor-alba.rb +3 -0
  162. data/plugins/rigor-alba/sig/alba.rbs +4 -0
  163. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +2 -2
  164. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +29 -12
  165. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +7 -6
  166. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +130 -21
  167. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +6 -4
  168. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +4 -4
  169. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +1 -1
  170. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1 -1
  171. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +31 -0
  172. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +102 -0
  173. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +87 -0
  174. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +76 -0
  175. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +73 -0
  176. data/plugins/rigor-typelizer/lib/rigor-typelizer.rb +3 -0
  177. data/sig/rigor/analysis/dependency_source_inference/index.rbs +1 -1
  178. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  179. data/sig/rigor/scope.rbs +27 -4
  180. data/sig/rigor/source.rbs +4 -0
  181. data/sig/rigor.rbs +3 -1
  182. metadata +66 -1
@@ -28,8 +28,11 @@ module Rigor
28
28
 
29
29
  # One constant reference. `from` is the fully-qualified name of the innermost enclosing declaration, or
30
30
  # nil for a reference written at file level — that is what makes the graph a reachability graph rather
31
- # than a reference count (ADR-102 WD2). `role` is the referring FILE's role (WD8). `rooted` is true
32
- # when the constant was written with a leading `::` (#625).
31
+ # than a reference count (ADR-102 WD2). A reference in a declaration's own header — `class Sub < Base`,
32
+ # `Sub = Class.new(Base)` — is credited to that declaration, while `nesting` stays the outer scope it
33
+ # resolves in (#1720); that header case is the only one where `from` is not `nesting` joined. `role` is
34
+ # the referring FILE's role (WD8). `rooted` is true when the constant was written with a leading `::`
35
+ # (#625).
33
36
  Reference = Data.define(:as_written, :nesting, :from, :role, :path, :line, :rooted) do
34
37
  def initialize(rooted: false, **) = super
35
38
  end
@@ -45,31 +48,69 @@ module Rigor
45
48
  # children (#370). `config/recurring.yml` mentioning `Admin` is not evidence about
46
49
  # `Admin::CollectionPolicy`; treating it as such demoted 18 unrelated Mastodon rows off one
47
50
  # Afrikaans word ("Administrasie" contains "Admin").
48
- DynamicUse = Data.define(:name, :prefix, :reason, :site, :path, :line, :scope) do
49
- def initialize(scope: :namespace, **) = super
51
+ # - `:leading` — `"V#{version}"` is the START of a name, not a namespace, so it can construct `V1_0`
52
+ # and `V2_2` as well as anything beneath them: a string-prefix match (#1734).
53
+ #
54
+ # `within` is set for `Foo.const_get("Bar::#{k}")`: `prefix` is then relative to the receiver, a
55
+ # {Reference} that {Graph} resolves as it resolves any other before matching.
56
+ DynamicUse = Data.define(:name, :prefix, :reason, :site, :path, :line, :scope, :within) do
57
+ def initialize(scope: :namespace, within: nil, **) = super
50
58
 
51
59
  # Whether this site's evidence reaches `fqn`.
52
60
  def taints?(fqn)
53
61
  return false if prefix.nil?
54
62
  return fqn == prefix if scope == :exact
63
+ return fqn.start_with?(prefix) if scope == :leading
55
64
 
56
65
  fqn == prefix || fqn.start_with?("#{prefix}::")
57
66
  end
67
+
68
+ # This site with `within` resolved by the block into absolute `prefix`es. A resolution of the whole
69
+ # written path is the anchor. {Graph#resolve} peels an unknown `Foo::Bar` to `Foo`, though, and
70
+ # anchoring there alone looked for `Foo::V*` and missed `Foo::Bar::V1`; the written name is then the
71
+ # anchor as well as the peeled one, since an undeclared receiver may be an alias of either. A
72
+ # receiver that does not resolve at all keeps its written name.
73
+ def anchored
74
+ return [self] if within.nil?
75
+
76
+ written = within.as_written
77
+ resolved = yield(within)
78
+ anchors = resolved == written || resolved&.end_with?("::#{written}") ? [resolved] : [written, resolved]
79
+ anchors.compact.map { |anchor| with(prefix: "#{anchor}::#{prefix}", within: nil) }
80
+ end
58
81
  end
59
82
 
60
83
  Result = Data.define(:declarations, :references, :dynamic_uses)
61
84
 
62
85
  # Roles a referring file can have (ADR-102 WD8). A reference edge carries its referrer's role so
63
86
  # "used only by its own test" is a reportable category rather than a bucket boundary.
64
- def self.role_for(path)
87
+ #
88
+ # End-to-end suites (`qa/`, `e2e/`, GitLab's QA; `features/`, cucumber) are test code too (#1751). They
89
+ # are matched at the project root only: `app/models/features/` is an ordinary namespace directory, and
90
+ # a false :test would hide a production reference. Tooling directories (`rubocop/`, `keeps/`,
91
+ # `tooling/`, `scripts/`) stay :production on purpose — deleting a class they use breaks the tooling.
92
+ #
93
+ # The role is decided from the path RELATIVE to `root` (the project root), never from the absolute
94
+ # path: `/home/me/test/app/lib/a.rb` is not test code because a directory above the project is called
95
+ # `test`. With no `root` the path is taken as already relative.
96
+ def self.role_for(path, root: nil)
97
+ path = relative_to(path, root)
65
98
  case path
66
- when %r{(\A|/)(spec|test)/}, /_(spec|test)\.rb\z/ then :test
99
+ when %r{(\A|/)(spec|test)/}, /_(spec|test)\.rb\z/, %r{\A(\./)?(qa|e2e|features)/} then :test
67
100
  when /\.rake\z/, %r{(\A|/)(lib/)?tasks/} then :task
68
101
  when %r{(\A|/)config/} then :config
69
102
  else :production
70
103
  end
71
104
  end
72
105
 
106
+ def self.relative_to(path, root)
107
+ return path if root.nil?
108
+
109
+ prefix = "#{root.to_s.chomp('/')}/"
110
+ path.start_with?(prefix) ? path.delete_prefix(prefix) : path
111
+ end
112
+ private_class_method :relative_to
113
+
73
114
  # A constant name is ASCII by construction, so a byte sequence that is not valid UTF-8 cannot be one.
74
115
  # Dropping it is both correct and the only safe answer: carrying it forward crashed the whole run on
75
116
  # the first `String#sub` downstream, which is how this surfaced — `rigor unused` on Rigor's own
@@ -84,9 +125,10 @@ module Rigor
84
125
  # @param path — the file's path, as the report should render it.
85
126
  # @param source — the file's bytes.
86
127
  # @param target_ruby — Prism version string, threaded from the project configuration.
128
+ # @param root — the project root `path` is made relative to when its role is decided.
87
129
  # @return nil when the file does not parse (a parse error is the analyzer's business, not
88
130
  # this scan's — it simply contributes nothing rather than half a file).
89
- def self.call(path:, source:, target_ruby: nil)
131
+ def self.call(path:, source:, target_ruby: nil, root: nil)
90
132
  parsed = if target_ruby
91
133
  Prism.parse(source, filepath: path,
92
134
  version: target_ruby)
@@ -95,12 +137,66 @@ module Rigor
95
137
  end
96
138
  return nil unless parsed.success?
97
139
 
98
- walker = Walker.new(path: path, role: role_for(path))
140
+ walker = Walker.new(path: path, role: role_for(path, root: root))
99
141
  walker.walk(parsed.value, [])
100
142
  Result.new(declarations: walker.declarations.freeze, references: walker.references.freeze,
101
143
  dynamic_uses: walker.dynamic_uses.freeze)
102
144
  end
103
145
 
146
+ # The values a local variable can hold, read off its assignments in one local scope, for a
147
+ # dynamic-resolution argument built a line before the call (#1734).
148
+ module LocalValues
149
+ module_function
150
+
151
+ # The assigned value nodes of `name` in `scope`, or nil when there is none or one of the assignments
152
+ # cannot be read off its node (`+=`, a multiple assignment, `rescue => name`).
153
+ def values(scope, name)
154
+ writes = []
155
+ collect_local_writes(scope, name, writes)
156
+ return nil if writes.empty? || writes.any? { |write| !VALUE_WRITES.include?(write.class) }
157
+
158
+ writes.map(&:value)
159
+ end
160
+
161
+ # The names a block's parameter list binds, block-locals included.
162
+ def bindings(parameters)
163
+ out = []
164
+ collect_block_bindings(parameters, out)
165
+ out
166
+ end
167
+
168
+ VALUE_WRITES = [Prism::LocalVariableWriteNode, Prism::LocalVariableOrWriteNode,
169
+ Prism::LocalVariableAndWriteNode].freeze
170
+ private_constant :VALUE_WRITES
171
+
172
+ LOCAL_WRITES = (VALUE_WRITES + [Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableTargetNode]).freeze
173
+ private_constant :LOCAL_WRITES
174
+
175
+ # Nodes that open a new local-variable scope. A block does not: it shares its method's locals.
176
+ SCOPE_GATES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
177
+ private_constant :SCOPE_GATES
178
+
179
+ def collect_local_writes(node, name, out)
180
+ node.rigor_each_child do |child|
181
+ next if SCOPE_GATES.any? { |gate| child.is_a?(gate) }
182
+
183
+ out << child if LOCAL_WRITES.any? { |kind| child.is_a?(kind) } && child.name == name
184
+ collect_local_writes(child, name, out)
185
+ end
186
+ end
187
+
188
+ BLOCK_BINDINGS = [Prism::RequiredParameterNode, Prism::OptionalParameterNode, Prism::RestParameterNode,
189
+ Prism::RequiredKeywordParameterNode, Prism::OptionalKeywordParameterNode,
190
+ Prism::KeywordRestParameterNode, Prism::BlockParameterNode,
191
+ Prism::BlockLocalVariableNode].freeze
192
+ private_constant :BLOCK_BINDINGS
193
+
194
+ def collect_block_bindings(node, out)
195
+ out << node.name if BLOCK_BINDINGS.any? { |kind| node.is_a?(kind) } && node.name
196
+ node.rigor_each_child { |child| collect_block_bindings(child, out) }
197
+ end
198
+ end
199
+
104
200
  # Single-pass walker. Tracks `nesting` as the stack of enclosing declaration names.
105
201
  class Walker
106
202
  attr_reader :declarations, :references, :dynamic_uses
@@ -111,6 +207,12 @@ module Rigor
111
207
  @declarations = []
112
208
  @references = []
113
209
  @dynamic_uses = []
210
+ # The declaration a meta-new rvalue is being walked for, or nil. See {#walk_constant_write}.
211
+ @owner = nil
212
+ # The node whose subtree holds the local variables in scope: the file, a method, or a class body.
213
+ @local_scope = nil
214
+ # Names an enclosing block's parameters or block-locals shadow within @local_scope.
215
+ @block_params = Set.new.freeze
114
216
  end
115
217
 
116
218
  def walk(node, nesting)
@@ -124,13 +226,21 @@ module Rigor
124
226
  # A constant path's segments are not separate references — `A::B::C` is one reference to the leaf,
125
227
  # and descending would record `A` and `A::B` as references in their own right (22 spurious
126
228
  # candidates on Rigor's own lib came from exactly that in the #345 probe).
229
+ #
230
+ # A path on a computed base is the exception: `Migration[2.2]::MigrationRecord` names no constant
231
+ # this reading can resolve, but its base is a call whose receiver and arguments do.
232
+ walk(node.parent, nesting) if node.is_a?(Prism::ConstantPathNode) &&
233
+ Source::ConstantPath.qualified_name_or_nil(node).nil?
127
234
  return
128
235
  when Prism::ConstantWriteNode
129
- record_meta_new(node, nesting)
130
- walk(node.value, nesting)
236
+ walk_constant_write(node, nesting)
131
237
  return
132
238
  when Prism::CallNode
133
- record_dynamic_use(node)
239
+ record_dynamic_use(node, nesting)
240
+ when Prism::ProgramNode, Prism::DefNode, Prism::SingletonClassNode
241
+ return in_local_scope(node) { node.rigor_each_child { |child| walk(child, nesting) } }
242
+ when Prism::BlockNode, Prism::LambdaNode
243
+ return in_block(node) { node.rigor_each_child { |child| walk(child, nesting) } }
134
244
  end
135
245
 
136
246
  node.rigor_each_child { |child| walk(child, nesting) }
@@ -144,13 +254,27 @@ module Rigor
144
254
 
145
255
  fqn = (nesting + [name]).join("::")
146
256
  # The superclass position IS a reference — `class Sub < Base` reads `Base` — and it resolves against
147
- # the OUTER nesting, not inside the body being opened.
148
- record_reference(superclass, nesting) if superclass
257
+ # the OUTER nesting, not inside the body being opened. It is part of `Sub`'s declaration, though, so
258
+ # it is credited to `Sub` (#1720): crediting the enclosing scope left a base nested in a module that
259
+ # is never itself reached unreachable, and rooted a top-level base even when every subclass is dead.
260
+ #
261
+ # The superclass is WALKED, not only read as a name: `< DelegateClass(Foo)`, `< Struct.new(:a,
262
+ # Foo::X)` and `< ActiveRecord::Migration[7.1]` are calls whose receiver and arguments name
263
+ # constants, and reading only a constant node recorded none of them, so `Foo` was a false candidate
264
+ # (#1733). Its references take the credit a meta-new rvalue's do.
265
+ walk_owned(superclass, nesting, fqn) if superclass
149
266
  includes = node.body ? mixin_names(node.body) : []
150
267
  @declarations << Declaration.new(fqn: fqn, path: @path, line: node.location.start_line,
151
268
  superclass: superclass && Source::ConstantPath.qualified_name(superclass),
152
269
  includes: includes.freeze)
153
- walk(node.body, nesting + [name]) if node.body
270
+ return unless node.body
271
+
272
+ # The body's nesting names the declaration itself, so a credit inherited from a meta-new rvalue
273
+ # this declaration sits in must not leak into it.
274
+ owner = @owner
275
+ @owner = nil
276
+ in_local_scope(node.body) { walk(node.body, nesting + [name]) }
277
+ @owner = owner
154
278
  end
155
279
 
156
280
  # `Const = Class.new` / `Module.new` / `Data.define(...)` / `Struct.new(...)` declare a class under a
@@ -160,6 +284,26 @@ module Rigor
160
284
  META_NEW = { "Class" => :new, "Module" => :new, "Data" => :define, "Struct" => :new }.freeze
161
285
  private_constant :META_NEW
162
286
 
287
+ # A meta-new rvalue — `Class.new(Base) { ... }`, `Struct.new(:a) do ... end` — is the declaration's
288
+ # header and body, so the references in it are credited to the declared constant, exactly as a
289
+ # `class Sub < Base` superclass is (#1720). Its lexical nesting is unchanged: a block opens no cref.
290
+ def walk_constant_write(node, nesting)
291
+ fqn = record_meta_new(node, nesting)
292
+ return walk(node.value, nesting) if fqn.nil?
293
+
294
+ walk_owned(node.value, nesting, fqn)
295
+ end
296
+
297
+ # Walks a declaration's header expression in the outer `nesting` it is evaluated in, crediting the
298
+ # references in it to the declared `fqn`.
299
+ def walk_owned(node, nesting, fqn)
300
+ owner = @owner
301
+ @owner = fqn
302
+ walk(node, nesting)
303
+ @owner = owner
304
+ end
305
+
306
+ # @return the declared FQN, or nil when the rvalue is not a meta-new form.
163
307
  def record_meta_new(node, nesting)
164
308
  call = node.value
165
309
  return unless call.is_a?(Prism::CallNode)
@@ -167,8 +311,10 @@ module Rigor
167
311
  recv = call.receiver
168
312
  return unless recv.is_a?(Prism::ConstantReadNode) && META_NEW[recv.name.to_s] == call.name
169
313
 
170
- @declarations << Declaration.new(fqn: (nesting + [node.name.to_s]).join("::"), path: @path,
314
+ fqn = (nesting + [node.name.to_s]).join("::")
315
+ @declarations << Declaration.new(fqn: fqn, path: @path,
171
316
  line: node.location.start_line, superclass: nil, includes: [].freeze)
317
+ fqn
172
318
  end
173
319
 
174
320
  # `include` / `prepend` / `extend` argument names written directly in the declaration body. Only the
@@ -189,6 +335,10 @@ module Rigor
189
335
  DYNAMIC_RESOLVERS = %i[constantize safe_constantize const_get].freeze
190
336
  private_constant :DYNAMIC_RESOLVERS
191
337
 
338
+ # The anchor of a lookup that starts at the top level.
339
+ TOP_LEVEL = ""
340
+ private_constant :TOP_LEVEL
341
+
192
342
  SUBJECT_SHAPES = {
193
343
  Prism::StringNode => "a literal string",
194
344
  Prism::SymbolNode => "a literal symbol",
@@ -198,59 +348,163 @@ module Rigor
198
348
 
199
349
  # Rigor knows the argument's shape, which is the whole reason this can be tiered rather than treated
200
350
  # as a blanket namespace poison: a literal argument names the exact constant and is as good as a
201
- # written reference, while an interpolated one can only bound the namespace it reaches into.
202
- def record_dynamic_use(node)
351
+ # written reference, while an interpolated one can only bound the names its literal head reaches.
352
+ #
353
+ # A local variable argument is read through its assignments in the same local scope, because the
354
+ # name is usually built one line up: GitLab's `Migration[2.2]` is `name = "V#{...}"` followed by
355
+ # `const_get(name, false)` (#1734).
356
+ def record_dynamic_use(node, nesting)
203
357
  return unless DYNAMIC_RESOLVERS.include?(node.name)
204
358
 
205
359
  subject = node.name == :const_get ? node.arguments&.arguments&.first : node.receiver
206
360
  return if subject.nil?
207
361
 
208
- name, prefix = dynamic_target(subject)
362
+ anchors = node.name == :const_get ? const_get_anchors(node, nesting) : [TOP_LEVEL]
363
+ subjects(subject).each { |value| record_dynamic_subject(node, value, anchors) }
364
+ end
365
+
366
+ def record_dynamic_subject(node, subject, anchors)
367
+ name, head = dynamic_target(subject)
209
368
  # A literal whose bytes are not valid UTF-8 cannot name a constant; dropping it is the only safe
210
369
  # answer, and carrying it forward crashed the whole run downstream.
211
370
  return if subject.is_a?(Prism::StringNode) && name.nil?
212
371
  return if subject.is_a?(Prism::SymbolNode) && name.nil?
213
372
 
214
- @dynamic_uses << DynamicUse.new(
215
- name: name, prefix: prefix, site: site(node), path: @path, line: node.location.start_line,
216
- reason: "#{node.name} on #{SUBJECT_SHAPES.fetch(subject.class, 'a computed value')}"
217
- )
373
+ reason = "#{node.name} on #{SUBJECT_SHAPES.fetch(subject.class, 'a computed value')}"
374
+ bounds = head ? anchors.filter_map { |anchor| bound(head, anchor) }.uniq : []
375
+ bounds = [[nil, :namespace, nil]] if bounds.empty?
376
+ bounds.each do |prefix, scope, within|
377
+ @dynamic_uses << DynamicUse.new(name: name, prefix: prefix, scope: scope, within: within,
378
+ site: site(node), path: @path, line: node.location.start_line,
379
+ reason: reason)
380
+ end
381
+ end
382
+
383
+ # Where a `const_get` looks a name up, as the namespaces its interpolated argument is bounded by. The
384
+ # receiver's namespace comes first: an implicit receiver, `self` or `self.class` is the enclosing
385
+ # declaration, and a constant receiver is resolved by {Graph} as any reference is (`within`). With
386
+ # the default `inherit = true` the lookup also falls through to the top level, which is all the
387
+ # reading had before, so that stays tainted too; an explicit `false` confines it to the receiver. An
388
+ # unknown receiver bounds nothing beyond the top level.
389
+ def const_get_anchors(node, nesting)
390
+ receiver = node.receiver
391
+ namespaces =
392
+ if self_receiver?(receiver)
393
+ # Inside a meta-new rvalue `self` is the declared class in its block but the outer scope in its
394
+ # arguments; the walk does not tell the two apart, so both are taken.
395
+ [@owner, nesting.join("::")].compact.uniq - [TOP_LEVEL]
396
+ elsif constant_receiver?(receiver)
397
+ [receiver_reference(receiver, nesting)]
398
+ else
399
+ []
400
+ end
401
+ return [TOP_LEVEL] if namespaces.empty?
402
+
403
+ inherit = node.arguments.arguments[1]
404
+ inherit.is_a?(Prism::FalseNode) ? namespaces : namespaces + [TOP_LEVEL]
405
+ end
406
+
407
+ def self_receiver?(receiver)
408
+ receiver.nil? || receiver.is_a?(Prism::SelfNode) ||
409
+ (receiver.is_a?(Prism::CallNode) && receiver.name == :class && receiver.receiver.is_a?(Prism::SelfNode))
218
410
  end
219
411
 
220
- # `[exact name, bounded namespace]` for a dynamic-resolution subject. A literal names its constant
221
- # exactly; an interpolation can only bound the namespace its literal head names; anything else bounds
222
- # nothing.
412
+ # `Object.const_get` is the top level, which every lookup already reaches.
413
+ def constant_receiver?(receiver)
414
+ return false unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
415
+
416
+ as_written = Source::ConstantPath.qualified_name_or_nil(receiver)
417
+ !as_written.nil? && as_written.delete_prefix("::") != "Object"
418
+ end
419
+
420
+ def receiver_reference(receiver, nesting)
421
+ Reference.new(as_written: Source::ConstantPath.qualified_name_or_nil(receiver),
422
+ nesting: nesting.dup.freeze, from: nil, role: @role, path: @path,
423
+ line: receiver.location.start_line, rooted: Source::ConstantPath.rooted?(receiver))
424
+ end
425
+
426
+ # `[prefix, scope, within]` for a literal head under one anchor. A head ending in `::` names a
427
+ # namespace and reaches everything beneath it; any other head is the start of a name, so
428
+ # `"V#{version}"` reaches `V1_0` and `V2_2` alike, and only a string-prefix match says that. A rooted
429
+ # head ignores the receiver. `within` carries a constant receiver for {Graph} to resolve.
430
+ def bound(head, anchor)
431
+ anchor = TOP_LEVEL if head.start_with?("::")
432
+ head = head.delete_prefix("::")
433
+ scope = head.end_with?("::") ? :namespace : :leading
434
+ head = head.delete_suffix("::")
435
+ return nil if head.empty?
436
+
437
+ case anchor
438
+ when Reference then [head, scope, anchor]
439
+ when TOP_LEVEL then [head, scope, nil]
440
+ else ["#{anchor}::#{head}", scope, nil]
441
+ end
442
+ end
443
+
444
+ # The values a dynamic-resolution subject can hold: the subject itself, or for a local variable every
445
+ # value assigned to it in the enclosing local scope. A local with an assignment whose value cannot be
446
+ # read off the node (`+=`, a multiple assignment, `rescue => name`) stays a computed value.
447
+ #
448
+ # A name an enclosing block binds as a parameter or block-local (`|name|`, `|x; name|`) is that
449
+ # block's value, not the method's: reading it through the method's assignments named a literal the
450
+ # block never sees, and a constant only that literal named was hidden from the report.
451
+ def subjects(subject)
452
+ return [subject] unless subject.is_a?(Prism::LocalVariableReadNode) && @local_scope
453
+ return [subject] if @block_params.include?(subject.name)
454
+
455
+ LocalValues.values(@local_scope, subject.name) || [subject]
456
+ end
457
+
458
+ def in_local_scope(scope)
459
+ saved = [@local_scope, @block_params]
460
+ @local_scope = scope
461
+ @block_params = Set.new.freeze
462
+ yield
463
+ ensure
464
+ @local_scope, @block_params = saved
465
+ end
466
+
467
+ def in_block(block)
468
+ saved = @block_params
469
+ names = block.parameters ? LocalValues.bindings(block.parameters) : []
470
+ @block_params = (@block_params | names).freeze unless names.empty?
471
+ yield
472
+ ensure
473
+ @block_params = saved
474
+ end
475
+
476
+ # `[exact name, literal head]` for a dynamic-resolution subject. A literal names its constant exactly;
477
+ # an interpolation can only bound the names its literal head starts; anything else bounds nothing.
223
478
  def dynamic_target(subject)
224
479
  case subject
225
480
  when Prism::StringNode, Prism::SymbolNode then [Scan.usable_name(subject.unescaped), nil]
226
- when Prism::InterpolatedStringNode then [nil, literal_prefix(subject)]
481
+ when Prism::InterpolatedStringNode then [nil, literal_head(subject)]
227
482
  else [nil, nil]
228
483
  end
229
484
  end
230
485
 
231
- # The literal head of an interpolated name: `"Foo::Bar::#{k}"` bounds the reach to `Foo::Bar`. Returns
232
- # nil when the interpolation starts the string, which bounds nothing.
233
486
  def site(node)
234
487
  "#{@path}:#{node.location.start_line}"
235
488
  end
236
489
 
237
- def literal_prefix(node)
490
+ # The literal head of an interpolated name, as written: `"Foo::Bar::#{k}"` gives `Foo::Bar::`. Returns
491
+ # nil when the interpolation starts the string, which bounds nothing.
492
+ def literal_head(node)
238
493
  head = node.parts.first
239
494
  return nil unless head.is_a?(Prism::StringNode)
240
495
 
241
496
  literal = Scan.usable_name(head.unescaped)
242
- return nil if literal.nil?
243
-
244
- trimmed = literal.sub(/::\z/, "")
245
- trimmed.empty? ? nil : trimmed
497
+ literal.nil? || literal.empty? ? nil : literal
246
498
  end
247
499
 
248
- def record_reference(node, nesting)
500
+ # `from` defaults to the innermost enclosing declaration; a declaration's own header (its superclass,
501
+ # its meta-new rvalue) is credited to that declaration instead.
502
+ def record_reference(node, nesting, from: @owner)
249
503
  as_written = Source::ConstantPath.qualified_name_or_nil(node)
250
504
  return if as_written.nil?
251
505
 
252
- @references << Reference.new(as_written: as_written, nesting: nesting.dup.freeze,
253
- from: nesting.empty? ? nil : nesting.join("::"),
506
+ from ||= nesting.empty? ? nil : nesting.join("::")
507
+ @references << Reference.new(as_written: as_written, nesting: nesting.dup.freeze, from: from,
254
508
  role: @role, path: @path, line: node.location.start_line,
255
509
  rooted: Source::ConstantPath.rooted?(node))
256
510
  end
@@ -292,6 +292,38 @@ module Rigor
292
292
  since: "0.0.1"
293
293
  ),
294
294
 
295
+ CheckRules::RULE_DEPRECATED_RUBY2_KEYWORDS => Entry.new(
296
+ id: CheckRules::RULE_DEPRECATED_RUBY2_KEYWORDS,
297
+ summary: "A call to the ruby2_keywords family Ruby 4.1 deprecates, under an explicit target_ruby >= 4.1.",
298
+ fires_when: [
299
+ "`.rigor.yml` sets `target_ruby` explicitly to \"4.1\" or later.",
300
+ "The call reaches `Module#ruby2_keywords` (no receiver or `self` in a class / module body, a " \
301
+ "`class << self` body or a singleton method), top-level `ruby2_keywords` (at the top level, outside " \
302
+ "any block), or `Proc#ruby2_keywords` (a receiver typed `Proc`): removed in Ruby 4.4.",
303
+ "The call reaches `Hash.ruby2_keywords_hash?` or `Hash.ruby2_keywords_hash` (on `Hash` or a subclass " \
304
+ "RBS knows): removed in Ruby 4.5.",
305
+ "The same names through `send` / `__send__` with a literal Symbol."
306
+ ],
307
+ does_not_fire_when: [
308
+ "`target_ruby` is absent, is the default, is below 4.1, or is \"latest\" (which names a parser, " \
309
+ "not a runtime).",
310
+ "A project or `pre_eval:` file defines a method by the same name anywhere.",
311
+ "The call sits in a block at the top level, in an `instance_eval` / `instance_exec` block, in an " \
312
+ "instance method of a class or module, or on an explicit receiver that is not `Proc` / `Hash`.",
313
+ "The call sits in a version-guard arm that cannot run on the stated Ruby, under a guard on " \
314
+ "`RUBY_VERSION` that cannot be decided, or after a guard that returns or raises."
315
+ ],
316
+ suppression: "`# rigor:disable call.deprecated-ruby2-keywords` on the call line. To fix it instead, " \
317
+ "rewrite the delegation the flag serves to `(...)` or `(*args, **kwargs, &block)` first, then " \
318
+ "drop the `ruby2_keywords` call: removing it alone breaks keyword pass-through.",
319
+ severity_authored: :warning,
320
+ severity_by_profile: { lenient: :info, balanced: :warning, strict: :error },
321
+ # Each call names a known core method on a receiver whose class the syntax or the scope fixes, and a
322
+ # project definition of the name silences it. A file loaded only on an older Ruby is the residual.
323
+ evidence_tier: :high,
324
+ since: "0.4.2"
325
+ ),
326
+
295
327
  CheckRules::RULE_ALWAYS_RAISES => Entry.new(
296
328
  id: CheckRules::RULE_ALWAYS_RAISES,
297
329
  summary: "Call provably raises (today: Integer division-by-zero).",
@@ -552,6 +584,9 @@ module Rigor
552
584
  ],
553
585
  does_not_fire_when: [
554
586
  "Override raises or preserves visibility (only reductions break substitutability).",
587
+ "The method is one Ruby makes private wherever it is defined (`initialize`, " \
588
+ "`initialize_copy` / `_dup` / `_clone`, `respond_to_missing?`), so its section is not its " \
589
+ "runtime visibility (ADR-35 WD10).",
555
590
  "The shadowed method lives on an RBS-known / third-party ancestor (RBS models only " \
556
591
  "public/private; RBS-parent visibility is a deferred follow-on).",
557
592
  "`def self.foo` singleton methods (visibility is instance-side only).",
@@ -578,6 +613,9 @@ module Rigor
578
613
  does_not_fire_when: [
579
614
  "Either side lacks an authored RBS signature (WD1 both-sides-authored gate).",
580
615
  "The override narrows or preserves the return (covariant-safe).",
616
+ "`initialize` and the `initialize_copy` / `_dup` / `_clone` hooks: `Class#new` / `dup` / " \
617
+ "`clone` reach them on the receiver's own class and discard their return, outside " \
618
+ "substitutability (ADR-35 WD10).",
581
619
  "The ancestor's return is `untyped` / `self` / an unbound generic (degrades to " \
582
620
  "`Dynamic[Top]`, which accepts everything — FP-safe).",
583
621
  "The subtype relationship between the two return types is not resolvable from loaded " \
@@ -609,6 +647,9 @@ module Rigor
609
647
  does_not_fire_when: [
610
648
  "Either side lacks an authored RBS signature (WD1 both-sides-authored gate).",
611
649
  "The override widens or preserves the parameter (contravariant-safe).",
650
+ "`initialize` and the `initialize_copy` / `_dup` / `_clone` hooks: `Class#new` / `dup` / " \
651
+ "`clone` reach them only on the receiver's own class, so no parent-typed caller passes the " \
652
+ "parent's arguments (ADR-35 WD10).",
612
653
  "Either side is overloaded (more than one method type — arm mapping is ambiguous).",
613
654
  "The ancestor's parameter is `untyped` / an unbound generic / an interface (degrades " \
614
655
  "to `Dynamic[Top]`, which is passable to anything — FP-safe).",
@@ -640,6 +681,8 @@ module Rigor
640
681
  ],
641
682
  does_not_fire_when: [
642
683
  "Later write is `nil` — the `@cache = nil` clear-idiom is allowlisted.",
684
+ "The class's RBS declares the ivar (`@var: T`, or the slot an `attr_*` member implies, own or " \
685
+ "inherited) and that type accepts both the first write and the later one.",
643
686
  "Either side is Union / Dynamic / IntegerRange / a shape-varied carrier.",
644
687
  "Writes live in different classes that happen to share an ivar name.",
645
688
  "Writes are in a singleton body — `def self.foo` or a `class << self` def — those track " \
@@ -21,6 +21,9 @@ require_relative "../environment/default_libraries"
21
21
  # lockfile paths without loading `rigor/environment` or the RBS machinery.
22
22
  require_relative "../environment/lockfile_resolver"
23
23
  require_relative "../environment/rbs_collection_discovery"
24
+ # A leaf file (it loads only the cache descriptor), unlike `dependency_source_inference`, whose walker loads the
25
+ # inference engine the boot-slimming probe exists to avoid.
26
+ require_relative "dependency_source_inference/gem_resolver"
24
27
  require_relative "shadow_harness"
25
28
 
26
29
  module Rigor
@@ -93,6 +96,7 @@ module Rigor
93
96
  def descriptor(configuration:, files:, explain:, rbs_config_entries:, template_units_digest: nil)
94
97
  Cache::Descriptor.new(
95
98
  gems: [Cache::RbsDescriptor.rbs_gem_entry],
99
+ dependencies: dependency_source_entries(configuration),
96
100
  configs: rbs_config_entries + engine_source_entries + lockfile_entries(configuration) +
97
101
  template_unit_entries(template_units_digest) + shadow_harness_entries + [
98
102
  config_entry("configuration", Marshal.dump(configuration.to_h)),
@@ -151,6 +155,14 @@ module Rigor
151
155
  [lockfile_entry("bundler.lockfile", bundler), lockfile_entry("rbs_collection.lockfile", collection)]
152
156
  end
153
157
 
158
+ # ADR-10 slice 3, issue #1814 — the `dependencies.source_inference` gems' `(name, version, mode)` rows, which the
159
+ # walker's output depends on and no lockfile digest covers when the project has none or the gem sits beside the
160
+ # bundle. Shared by this key and by {Cache::IncrementalSnapshot.fingerprint}, as the lockfiles are. A project
161
+ # that lists no gem adds no row, so its key is what it was.
162
+ def dependency_source_entries(configuration)
163
+ DependencySourceInference::GemResolver.dependency_entries(configuration.dependencies)
164
+ end
165
+
154
166
  # The one resolution of the two dependency lockfiles — `[bundler, rbs_collection]`, each a Pathname or nil
155
167
  # — shared by this key and by {Cache::IncrementalSnapshot.fingerprint} so the two caches identify the gem
156
168
  # set by the same files (issue #1532): `bundler.lockfile:` / `rbs_collection.lockfile:` and auto-detection.
@@ -180,7 +192,10 @@ module Rigor
180
192
 
181
193
  # The `rbs.libraries` config slot reconstructed from configuration alone — byte-identical to the
182
194
  # loader's `RbsDescriptor.libraries_entry(loader.libraries)` because `Environment.for_project` merges
183
- # exactly `DEFAULT_LIBRARIES + config.libraries` (uniq) into `loader.libraries`.
195
+ # exactly `DEFAULT_LIBRARIES + config.libraries` (uniq) into `loader.libraries`, plus the
196
+ # `Environment::RequiredFeatures` tokens the run's source activates (issue #1700). Those are left out
197
+ # here: reading every source file would cost the boot-slimming probe the time it exists to save, and a
198
+ # project that requires a gated feature then only misses this probe, never hits a wrong slot.
184
199
  def libraries_config_entries(configuration)
185
200
  merged = (Environment::DEFAULT_LIBRARIES + configuration.libraries.map(&:to_s)).uniq
186
201
  [Cache::RbsDescriptor.libraries_entry(merged)]