rigortype 0.4.1 → 0.4.3

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 (230) 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 +18 -5
  25. data/docs/handbook/appendix-liskov.md +5 -0
  26. data/docs/manual/02-cli-reference.md +19 -1
  27. data/docs/manual/03-configuration.md +7 -5
  28. data/docs/manual/04-diagnostics.md +2 -1
  29. data/docs/manual/05-inspecting-types.md +50 -0
  30. data/docs/manual/06-baseline.md +2 -0
  31. data/docs/manual/07-plugins.md +1 -1
  32. data/docs/manual/12-caching.md +18 -1
  33. data/docs/manual/14-rails-quickstart.md +2 -2
  34. data/docs/manual/18-removing-dead-code.md +41 -4
  35. data/docs/manual/plugins/README.md +6 -1
  36. data/docs/manual/plugins/rigor-activerecord.md +21 -0
  37. data/docs/manual/plugins/rigor-alba.md +60 -0
  38. data/docs/manual/plugins/rigor-minitest.md +22 -4
  39. data/docs/manual/plugins/rigor-typelizer.md +71 -0
  40. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +27 -3
  41. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +21 -92
  42. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +275 -0
  43. data/lib/rigor/analysis/check_rules/rule_ids.rb +11 -0
  44. data/lib/rigor/analysis/check_rules/source_arity.rb +21 -2
  45. data/lib/rigor/analysis/check_rules.rb +292 -59
  46. data/lib/rigor/analysis/dependency_recorder.rb +15 -0
  47. data/lib/rigor/analysis/dependency_source_inference/builder.rb +8 -1
  48. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +21 -0
  49. data/lib/rigor/analysis/dependency_source_inference/index.rb +36 -11
  50. data/lib/rigor/analysis/dependency_source_inference/walker.rb +196 -27
  51. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  52. data/lib/rigor/analysis/erb_template_detector.rb +2 -1
  53. data/lib/rigor/analysis/incremental.rb +29 -9
  54. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +30 -5
  55. data/lib/rigor/analysis/incremental_run_slot.rb +6 -4
  56. data/lib/rigor/analysis/incremental_session.rb +103 -23
  57. data/lib/rigor/analysis/path_expansion.rb +62 -6
  58. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  59. data/lib/rigor/analysis/reachability/graph.rb +241 -37
  60. data/lib/rigor/analysis/reachability/scan.rb +490 -77
  61. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  62. data/lib/rigor/analysis/rule_catalog.rb +43 -0
  63. data/lib/rigor/analysis/run_cache_key.rb +16 -1
  64. data/lib/rigor/analysis/run_cache_probe.rb +1 -1
  65. data/lib/rigor/analysis/run_stats.rb +28 -6
  66. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +113 -6
  67. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -19
  68. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
  69. data/lib/rigor/analysis/runner/run_snapshots.rb +6 -1
  70. data/lib/rigor/analysis/runner.rb +62 -13
  71. data/lib/rigor/analysis/worker_session.rb +8 -5
  72. data/lib/rigor/cache/engine_source.rb +21 -7
  73. data/lib/rigor/cache/incremental_snapshot.rb +55 -5
  74. data/lib/rigor/cli/annotate_command.rb +5 -0
  75. data/lib/rigor/cli/baseline_command.rb +7 -18
  76. data/lib/rigor/cli/check_command.rb +140 -40
  77. data/lib/rigor/cli/docs_command.rb +5 -4
  78. data/lib/rigor/cli/lens_command.rb +144 -0
  79. data/lib/rigor/cli/lsp_command.rb +5 -2
  80. data/lib/rigor/cli/skill_command.rb +3 -3
  81. data/lib/rigor/cli/skill_describe.rb +1 -1
  82. data/lib/rigor/cli/trace_command.rb +5 -0
  83. data/lib/rigor/cli/type_of_command.rb +6 -3
  84. data/lib/rigor/cli/unused_command.rb +166 -27
  85. data/lib/rigor/cli.rb +39 -5
  86. data/lib/rigor/configuration/severity_profile.rb +6 -0
  87. data/lib/rigor/configuration.rb +61 -2
  88. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  89. data/lib/rigor/environment/gated_signature_guard.rb +43 -0
  90. data/lib/rigor/environment/gated_signature_plan.rb +339 -0
  91. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  92. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  93. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  94. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  95. data/lib/rigor/environment/rbs_loader.rb +316 -14
  96. data/lib/rigor/environment/required_features.rb +208 -0
  97. data/lib/rigor/environment.rb +33 -8
  98. data/lib/rigor/inference/acceptance.rb +28 -3
  99. data/lib/rigor/inference/builtins/cruby_definers.rb +78 -0
  100. data/lib/rigor/inference/builtins/method_catalog.rb +3 -0
  101. data/lib/rigor/inference/captured_locals.rb +43 -3
  102. data/lib/rigor/inference/def_node_resolver.rb +23 -1
  103. data/lib/rigor/inference/expression_typer.rb +261 -29
  104. data/lib/rigor/inference/global_write_census.rb +7 -4
  105. data/lib/rigor/inference/guard_rebinding.rb +1 -1
  106. data/lib/rigor/inference/in_effect_refinements/proc_literals.rb +83 -0
  107. data/lib/rigor/inference/in_effect_refinements/refine_defs.rb +54 -0
  108. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +286 -0
  109. data/lib/rigor/inference/in_effect_refinements/walk.rb +276 -0
  110. data/lib/rigor/inference/in_effect_refinements.rb +256 -0
  111. data/lib/rigor/inference/key_presence_guard/scan.rb +281 -0
  112. data/lib/rigor/inference/key_presence_guard.rb +303 -0
  113. data/lib/rigor/inference/macro_block_self_type.rb +59 -12
  114. data/lib/rigor/inference/match_rebinding.rb +1 -1
  115. data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
  116. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  117. data/lib/rigor/inference/method_dispatcher/arithmetic_sequence_elements.rb +73 -0
  118. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +68 -0
  119. data/lib/rigor/inference/method_dispatcher/integer_step_block_params.rb +81 -0
  120. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -0
  121. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  122. data/lib/rigor/inference/method_dispatcher/keyword_arguments.rb +267 -0
  123. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  124. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  125. data/lib/rigor/inference/method_dispatcher/method_folding.rb +2 -1
  126. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  127. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +122 -105
  128. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +247 -28
  129. data/lib/rigor/inference/method_dispatcher/splat_arity.rb +71 -0
  130. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  131. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  132. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  133. data/lib/rigor/inference/method_dispatcher.rb +96 -14
  134. data/lib/rigor/inference/module_function_state.rb +2 -1
  135. data/lib/rigor/inference/narrowing.rb +20 -6
  136. data/lib/rigor/inference/object_mixins.rb +180 -0
  137. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  138. data/lib/rigor/inference/project_patched_methods.rb +12 -7
  139. data/lib/rigor/inference/project_patched_scanner.rb +108 -26
  140. data/lib/rigor/inference/refine_census.rb +239 -0
  141. data/lib/rigor/inference/refine_self.rb +317 -0
  142. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  143. data/lib/rigor/inference/refined_dispatch/own_method.rb +475 -0
  144. data/lib/rigor/inference/refined_dispatch.rb +320 -0
  145. data/lib/rigor/inference/repeated_or_writes.rb +2 -0
  146. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +13 -0
  147. data/lib/rigor/inference/scope_indexer.rb +827 -65
  148. data/lib/rigor/inference/statement_evaluator.rb +294 -51
  149. data/lib/rigor/inference/string_mutation.rb +9 -3
  150. data/lib/rigor/inference/synthetic_method_scanner.rb +2 -1
  151. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  152. data/lib/rigor/inference/version_guard.rb +19 -10
  153. data/lib/rigor/language_server/project_context.rb +87 -5
  154. data/lib/rigor/language_server/server.rb +18 -0
  155. data/lib/rigor/lens/anchor.rb +115 -0
  156. data/lib/rigor/lens/contract_reader.rb +118 -0
  157. data/lib/rigor/lens/document.rb +113 -0
  158. data/lib/rigor/lens/file_reader.rb +433 -0
  159. data/lib/rigor/lens/members.rb +153 -0
  160. data/lib/rigor/lens/parameters.rb +91 -0
  161. data/lib/rigor/lens/plugins.rb +40 -0
  162. data/lib/rigor/lens/run.rb +280 -0
  163. data/lib/rigor/lens/slot.rb +79 -0
  164. data/lib/rigor/lens.rb +40 -0
  165. data/lib/rigor/plugin/inflector.rb +1 -1
  166. data/lib/rigor/plugin/io_boundary.rb +42 -3
  167. data/lib/rigor/plugin/macro/block_as_method.rb +51 -8
  168. data/lib/rigor/project_environment.rb +48 -6
  169. data/lib/rigor/protection/mutation_cache.rb +3 -2
  170. data/lib/rigor/protection/mutator.rb +1 -1
  171. data/lib/rigor/reflection/constant_ancestors.rb +127 -11
  172. data/lib/rigor/reflection/constant_path.rb +13 -2
  173. data/lib/rigor/reflection.rb +40 -8
  174. data/lib/rigor/scope/discovery_index.rb +40 -1
  175. data/lib/rigor/scope/resolution_chain.rb +159 -11
  176. data/lib/rigor/scope.rb +211 -21
  177. data/lib/rigor/sig_gen/classification.rb +4 -1
  178. data/lib/rigor/sig_gen/generator.rb +100 -10
  179. data/lib/rigor/sig_gen/method_candidate.rb +2 -2
  180. data/lib/rigor/sig_gen/skip_reason_catalog.rb +12 -0
  181. data/lib/rigor/source/alias_names.rb +52 -0
  182. data/lib/rigor/source/literals.rb +3 -2
  183. data/lib/rigor/source/symbol_name.rb +20 -0
  184. data/lib/rigor/type/combinator.rb +34 -2
  185. data/lib/rigor/type/float_range.rb +4 -1
  186. data/lib/rigor/type/nominal.rb +6 -1
  187. data/lib/rigor/version.rb +1 -1
  188. data/plugins/rigor-ac-library-rb/lib/rigor/plugin/ac_library_rb.rb +28 -0
  189. data/plugins/rigor-ac-library-rb/lib/rigor-ac-library-rb.rb +3 -0
  190. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/convolution.rbs +7 -0
  191. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/data_structures.rbs +160 -0
  192. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/graph.rbs +94 -0
  193. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/math.rbs +42 -0
  194. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/modint.rbs +71 -0
  195. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/object.rbs +37 -0
  196. data/plugins/rigor-ac-library-rb/sig/ac_library_rb/segtree.rbs +83 -0
  197. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +2 -1
  198. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  199. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  200. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/association_roots.rb +100 -0
  201. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +94 -10
  202. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +43 -2
  203. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +3 -1
  204. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +149 -0
  205. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +74 -0
  206. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_index.rb +104 -0
  207. data/plugins/rigor-alba/lib/rigor/plugin/alba.rb +85 -0
  208. data/plugins/rigor-alba/lib/rigor-alba.rb +3 -0
  209. data/plugins/rigor-alba/sig/alba.rbs +4 -0
  210. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +2 -2
  211. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +29 -12
  212. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +7 -6
  213. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +130 -21
  214. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +6 -4
  215. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +4 -4
  216. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +1 -1
  217. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +1 -1
  218. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +31 -0
  219. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +102 -0
  220. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +87 -0
  221. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_index.rb +76 -0
  222. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +73 -0
  223. data/plugins/rigor-typelizer/lib/rigor-typelizer.rb +3 -0
  224. data/sig/rigor/analysis/dependency_source_inference/index.rbs +1 -1
  225. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  226. data/sig/rigor/scope.rbs +30 -4
  227. data/sig/rigor/source.rbs +4 -0
  228. data/sig/rigor.rbs +9 -4
  229. data/skills/rigor-project-init/references/02-configure.md +2 -2
  230. metadata +83 -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,83 @@ 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 a `const_get` on a known receiver — `Foo.const_get("Bar::#{k}")`, or an implicit
55
+ # receiver, `self` or `self.class`, which is the enclosing declaration written rooted: `prefix` is then
56
+ # relative to the receiver, a {Reference} that {Graph} resolves as it resolves any other before
57
+ # matching. A literal `name` keeps `within` too, and {Graph} looks it up from there (#1761). `inherit`
58
+ # is false when the call passes `inherit = false`, which confines the lookup to the receiver itself;
59
+ # otherwise the receiver's ancestors are searched as well. `role` is the referring file's (WD8).
60
+ DynamicUse = Data.define(:name, :prefix, :reason, :site, :path, :line, :scope, :within, :inherit, :role) do
61
+ def initialize(scope: :namespace, within: nil, inherit: true, role: :production, **) = super
50
62
 
51
63
  # Whether this site's evidence reaches `fqn`.
52
64
  def taints?(fqn)
53
65
  return false if prefix.nil?
54
66
  return fqn == prefix if scope == :exact
67
+ return fqn.start_with?(prefix) if scope == :leading
55
68
 
56
69
  fqn == prefix || fqn.start_with?("#{prefix}::")
57
70
  end
71
+
72
+ # The namespaces an interpolated site's `within` names, resolved by the block. A resolution of the whole
73
+ # written path is the anchor. {Graph#resolve} peels an unknown `Foo::Bar` to `Foo`, though, and
74
+ # anchoring there alone looked for `Foo::V*` and missed `Foo::Bar::V1`; the written name is then an
75
+ # anchor as well as the peeled one, since an undeclared receiver may be an alias of either. A receiver
76
+ # that does not resolve at all keeps its written name. Over-reaching is safe for a taint only; a
77
+ # literal anchors at {#receiver} alone.
78
+ def anchors
79
+ resolved = yield(within)
80
+ whole?(resolved) ? [resolved] : [within.as_written, resolved].compact
81
+ end
82
+
83
+ # The declaration `within` names when it resolves as WHOLE, or nil. A receiver resolved only by peeling
84
+ # its last segment is unknown: `const_get` never searches the receiver's lexical parent, so anchoring a
85
+ # literal there could resolve it to a dead constant and miss the live one.
86
+ def receiver
87
+ resolved = yield(within)
88
+ whole?(resolved) ? resolved : nil
89
+ end
90
+
91
+ def whole?(resolved)
92
+ written = within.as_written
93
+ resolved == written || resolved&.end_with?("::#{written}")
94
+ end
58
95
  end
59
96
 
60
97
  Result = Data.define(:declarations, :references, :dynamic_uses)
61
98
 
62
99
  # Roles a referring file can have (ADR-102 WD8). A reference edge carries its referrer's role so
63
100
  # "used only by its own test" is a reportable category rather than a bucket boundary.
64
- def self.role_for(path)
101
+ #
102
+ # End-to-end suites (`qa/`, `e2e/`, GitLab's QA; `features/`, cucumber) are test code too (#1751). They
103
+ # are matched at the project root only: `app/models/features/` is an ordinary namespace directory, and
104
+ # a false :test would hide a production reference. Tooling directories (`rubocop/`, `keeps/`,
105
+ # `tooling/`, `scripts/`) stay :production on purpose — deleting a class they use breaks the tooling.
106
+ #
107
+ # The role is decided from the path RELATIVE to `root` (the project root), never from the absolute
108
+ # path: `/home/me/test/app/lib/a.rb` is not test code because a directory above the project is called
109
+ # `test`. With no `root` the path is taken as already relative.
110
+ def self.role_for(path, root: nil)
111
+ path = relative_to(path, root)
65
112
  case path
66
- when %r{(\A|/)(spec|test)/}, /_(spec|test)\.rb\z/ then :test
113
+ when %r{(\A|/)(spec|test)/}, /_(spec|test)\.rb\z/, %r{\A(\./)?(qa|e2e|features)/} then :test
67
114
  when /\.rake\z/, %r{(\A|/)(lib/)?tasks/} then :task
68
115
  when %r{(\A|/)config/} then :config
69
116
  else :production
70
117
  end
71
118
  end
72
119
 
120
+ def self.relative_to(path, root)
121
+ return path if root.nil?
122
+
123
+ prefix = "#{root.to_s.chomp('/')}/"
124
+ path.start_with?(prefix) ? path.delete_prefix(prefix) : path
125
+ end
126
+ private_class_method :relative_to
127
+
73
128
  # A constant name is ASCII by construction, so a byte sequence that is not valid UTF-8 cannot be one.
74
129
  # Dropping it is both correct and the only safe answer: carrying it forward crashed the whole run on
75
130
  # the first `String#sub` downstream, which is how this surfaced — `rigor unused` on Rigor's own
@@ -84,9 +139,11 @@ module Rigor
84
139
  # @param path — the file's path, as the report should render it.
85
140
  # @param source — the file's bytes.
86
141
  # @param target_ruby — Prism version string, threaded from the project configuration.
142
+ # @param root — the project root `path` is made relative to when its role is decided.
87
143
  # @return nil when the file does not parse (a parse error is the analyzer's business, not
88
144
  # this scan's — it simply contributes nothing rather than half a file).
89
- def self.call(path:, source:, target_ruby: nil)
145
+ # `role`, when given, is the referring file's role, decided by the caller instead of from `path` (#1760).
146
+ def self.call(path:, source:, target_ruby: nil, root: nil, role: nil)
90
147
  parsed = if target_ruby
91
148
  Prism.parse(source, filepath: path,
92
149
  version: target_ruby)
@@ -95,14 +152,314 @@ module Rigor
95
152
  end
96
153
  return nil unless parsed.success?
97
154
 
98
- walker = Walker.new(path: path, role: role_for(path))
155
+ walker = Walker.new(path: path, role: role || role_for(path, root: root))
99
156
  walker.walk(parsed.value, [])
100
157
  Result.new(declarations: walker.declarations.freeze, references: walker.references.freeze,
101
158
  dynamic_uses: walker.dynamic_uses.freeze)
102
159
  end
103
160
 
161
+ # The values a local variable can hold, read off its assignments in one local scope, for a
162
+ # dynamic-resolution argument built a line before the call (#1734).
163
+ module LocalValues
164
+ module_function
165
+
166
+ # The assigned value nodes of `name` in `scope`, or nil when there is none or one of the assignments
167
+ # cannot be read off its node (`+=`, a multiple assignment, `rescue => name`).
168
+ def values(scope, name)
169
+ writes = []
170
+ collect_local_writes(scope, name, writes)
171
+ return nil if writes.empty? || writes.any? { |write| !VALUE_WRITES.include?(write.class) }
172
+
173
+ writes.map(&:value)
174
+ end
175
+
176
+ # The names a block's parameter list binds, block-locals included.
177
+ def bindings(parameters)
178
+ out = []
179
+ collect_block_bindings(parameters, out)
180
+ out
181
+ end
182
+
183
+ VALUE_WRITES = [Prism::LocalVariableWriteNode, Prism::LocalVariableOrWriteNode,
184
+ Prism::LocalVariableAndWriteNode].freeze
185
+ private_constant :VALUE_WRITES
186
+
187
+ LOCAL_WRITES = (VALUE_WRITES + [Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableTargetNode]).freeze
188
+ private_constant :LOCAL_WRITES
189
+
190
+ # Nodes that open a new local-variable scope. A block does not: it shares its method's locals.
191
+ SCOPE_GATES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
192
+ private_constant :SCOPE_GATES
193
+
194
+ def collect_local_writes(node, name, out)
195
+ node.rigor_each_child do |child|
196
+ next if SCOPE_GATES.any? { |gate| child.is_a?(gate) }
197
+
198
+ out << child if LOCAL_WRITES.any? { |kind| child.is_a?(kind) } && child.name == name
199
+ collect_local_writes(child, name, out)
200
+ end
201
+ end
202
+
203
+ BLOCK_BINDINGS = [Prism::RequiredParameterNode, Prism::OptionalParameterNode, Prism::RestParameterNode,
204
+ Prism::RequiredKeywordParameterNode, Prism::OptionalKeywordParameterNode,
205
+ Prism::KeywordRestParameterNode, Prism::BlockParameterNode,
206
+ Prism::BlockLocalVariableNode].freeze
207
+ private_constant :BLOCK_BINDINGS
208
+
209
+ def collect_block_bindings(node, out)
210
+ out << node.name if BLOCK_BINDINGS.any? { |kind| node.is_a?(kind) } && node.name
211
+ node.rigor_each_child { |child| collect_block_bindings(child, out) }
212
+ end
213
+ end
214
+
215
+ # `Const = Class.new` / `Module.new` / `Data.define(...)` / `Struct.new(...)` declare a class under a
216
+ # constant, and `ScopeIndexer#record_class_new_constant_decl` already treats them as class declarations
217
+ # for cross-file resolution. The report must agree, or every such constant reads as an unreferenced
218
+ # value rather than a class.
219
+ META_NEW = { "Class" => :new, "Module" => :new, "Data" => :define, "Struct" => :new }.freeze
220
+ private_constant :META_NEW
221
+
222
+ # ADR-102 WD4 — the {Walker}'s reading of dynamic constant resolution: `constantize` and `const_get`
223
+ # sites, the receivers a `const_get` looks its name up from, and the values its argument can hold.
224
+ module DynamicSites
225
+ private
226
+
227
+ # Names that turn a String into a constant. `constantize` / `safe_constantize` are ActiveSupport;
228
+ # `const_get` is core and may carry an explicit receiver (`Object.const_get`, `self.class.const_get`).
229
+ DYNAMIC_RESOLVERS = %i[constantize safe_constantize const_get].freeze
230
+ private_constant :DYNAMIC_RESOLVERS
231
+
232
+ # The anchor of a lookup that starts at the top level.
233
+ TOP_LEVEL = ""
234
+ private_constant :TOP_LEVEL
235
+
236
+ SUBJECT_SHAPES = {
237
+ Prism::StringNode => "a literal string",
238
+ Prism::SymbolNode => "a literal symbol",
239
+ Prism::InterpolatedStringNode => "an interpolated string",
240
+ Prism::InterpolatedSymbolNode => "an interpolated symbol"
241
+ }.freeze
242
+ private_constant :SUBJECT_SHAPES
243
+
244
+ # Calls that hand a string or symbol through unchanged, as far as the name it spells: `"V#{v}".freeze`.
245
+ PASS_THROUGH = %i[freeze to_s to_str to_sym dup -@ +@].freeze
246
+ private_constant :PASS_THROUGH
247
+
248
+ # Blocks whose `self` is not the enclosing declaration: a concern's `included` / `class_methods` body
249
+ # runs on the including class, and an eval block on its receiver.
250
+ SELF_REBINDING = %i[included prepended extended class_methods class_eval module_eval class_exec
251
+ module_exec instance_eval instance_exec].freeze
252
+ private_constant :SELF_REBINDING
253
+
254
+ # A call is a dynamic-resolution site, and its block may run with another `self` ({#rebinds_self?}).
255
+ def record_call(node, nesting)
256
+ record_dynamic_use(node, nesting)
257
+ @rebinding_blocks << node.block if node.block.is_a?(Prism::BlockNode) && rebinds_self?(node)
258
+ end
259
+
260
+ # Rigor knows the argument's shape, which is the whole reason this can be tiered rather than treated
261
+ # as a blanket namespace poison: a literal argument names the exact constant and is as good as a
262
+ # written reference, while an interpolated one can only bound the names its literal head reaches.
263
+ #
264
+ # A local variable argument is read through its assignments in the same local scope, because the
265
+ # name is usually built one line up: GitLab's `Migration[2.2]` is `name = "V#{...}"` followed by
266
+ # `const_get(name, false)` (#1734).
267
+ def record_dynamic_use(node, nesting)
268
+ return unless DYNAMIC_RESOLVERS.include?(node.name)
269
+
270
+ subject = node.name == :const_get ? node.arguments&.arguments&.first : node.receiver
271
+ return if subject.nil?
272
+
273
+ anchors = node.name == :const_get ? const_get_anchors(node, nesting) : [TOP_LEVEL]
274
+ subjects(subject).each { |value| record_dynamic_subject(node, value, anchors) }
275
+ end
276
+
277
+ def record_dynamic_subject(node, subject, anchors)
278
+ name, head = dynamic_target(subject)
279
+ # A literal whose bytes are not valid UTF-8 cannot name a constant; dropping it is the only safe
280
+ # answer, and carrying it forward crashed the whole run downstream.
281
+ return if subject.is_a?(Prism::StringNode) && name.nil?
282
+ return if subject.is_a?(Prism::SymbolNode) && name.nil?
283
+
284
+ reason = "#{node.name} on #{SUBJECT_SHAPES.fetch(subject.class, 'a computed value')}"
285
+ bounds = if name then literal_bounds(node, name, anchors)
286
+ elsif head then anchors.filter_map { |anchor| bound(head, anchor) }.uniq
287
+ else []
288
+ end
289
+ bounds = [[nil, :namespace, nil]] if bounds.empty?
290
+ bounds.each do |prefix, scope, within|
291
+ @dynamic_uses << DynamicUse.new(name: name, prefix: prefix, scope: scope, within: within,
292
+ inherit: anchors.include?(TOP_LEVEL), role: @role,
293
+ site: site(node), path: @path, line: node.location.start_line,
294
+ reason: reason)
295
+ end
296
+ end
297
+
298
+ # A literal name is looked up from each receiver the call can have, so it carries the receiver as
299
+ # `within` and {Graph} resolves it there first (#1761). A rooted name, or a call whose only anchor is
300
+ # the top level, is looked up from the top level as any reference is.
301
+ #
302
+ # On `self` the receiver is the enclosing declaration only where the walk can name it ({#self_lookup}).
303
+ # Where `self` is a `Class` or a singleton class, Ruby looks the name up at the top level. Where the
304
+ # walk cannot tell what `self` is, the name is read BOTH at the enclosing declaration and at the top
305
+ # level: either reading alone lists a live constant as a candidate when it is the wrong one, and a
306
+ # dead constant kept reachable is the cheaper mistake. An interpolated name keeps every anchor, since
307
+ # a taint is safe to over-reach.
308
+ def literal_bounds(node, name, anchors)
309
+ return [] if name.start_with?("::")
310
+
311
+ within = anchors.grep(Reference).map { |receiver| [nil, :namespace, receiver] }
312
+ return within unless self_receiver?(node.receiver)
313
+
314
+ case self_lookup(node.receiver)
315
+ when :declaration then within
316
+ when :top_level then []
317
+ else within + [[nil, :namespace, nil]]
318
+ end
319
+ end
320
+
321
+ # Where a `const_get` on `self` (implicit, `self`, or `self.class`) looks its name up: `:declaration`
322
+ # when `self` is the enclosing class or module, `:top_level` when it is `Class` or a singleton class,
323
+ # `:unknown` otherwise. A block's `self` is unknown — `included do`, `class_eval` and an instance-
324
+ # evaluated DSL block (`configure { }`, an RSpec `describe` body) all rebind it, and the walk cannot
325
+ # tell those from an `each` block. So is the `self` of a `def Other.x` or of a `class << Other` body.
326
+ def self_lookup(receiver)
327
+ return :unknown if @in_block || @self_rebound
328
+
329
+ class_of_self = receiver.is_a?(Prism::CallNode)
330
+ case @self_kind
331
+ when :declaration, :singleton_method then class_of_self ? :top_level : :declaration
332
+ when :instance_method then class_of_self ? :declaration : :unknown
333
+ when :singleton_class then :top_level
334
+ else :unknown
335
+ end
336
+ end
337
+
338
+ # Where a `const_get` looks a name up, as the namespaces its interpolated argument is bounded by. The
339
+ # receiver's namespace comes first: an implicit receiver, `self` or `self.class` is the enclosing
340
+ # declaration, and a constant receiver is resolved by {Graph} as any reference is (`within`). With
341
+ # the default `inherit = true` the lookup also falls through to the top level, which is all the
342
+ # reading had before, so that stays tainted too; an explicit `false` confines it to the receiver. An
343
+ # unknown receiver bounds nothing beyond the top level.
344
+ def const_get_anchors(node, nesting)
345
+ receiver = node.receiver
346
+ namespaces =
347
+ if self_receiver?(receiver)
348
+ self_references(node, nesting)
349
+ elsif constant_receiver?(receiver)
350
+ [receiver_reference(receiver, nesting)]
351
+ else
352
+ []
353
+ end
354
+ return [TOP_LEVEL] if namespaces.empty?
355
+
356
+ inherit = node.arguments.arguments[1]
357
+ inherit.is_a?(Prism::FalseNode) ? namespaces : namespaces + [TOP_LEVEL]
358
+ end
359
+
360
+ # The enclosing declaration as a rooted {Reference}, so {Graph} anchors it as it anchors a constant
361
+ # receiver and can search its ancestors. Inside a meta-new rvalue `self` is the declared class in its
362
+ # block but the outer scope in its arguments; the walk does not tell the two apart, so both are taken.
363
+ def self_references(node, nesting)
364
+ ([@owner, nesting.join("::")].compact.uniq - [TOP_LEVEL]).map do |fqn|
365
+ Reference.new(as_written: fqn, nesting: [].freeze, from: nil, role: @role, path: @path,
366
+ line: node.location.start_line, rooted: true)
367
+ end
368
+ end
369
+
370
+ # Whether `call`'s block runs with a `self` other than the enclosing declaration's. A meta-new block
371
+ # assigned to a constant is that constant ({#walk_constant_write} credits it as `@owner`); an
372
+ # anonymous one is not the enclosing declaration either.
373
+ def rebinds_self?(call)
374
+ return true if SELF_REBINDING.include?(call.name)
375
+
376
+ recv = call.receiver
377
+ name = recv && Source::ConstantPath.qualified_name_or_nil(recv)&.delete_prefix("::")
378
+ @owner.nil? && !name.nil? && META_NEW[name] == call.name
379
+ end
380
+
381
+ def self_receiver?(receiver)
382
+ receiver.nil? || receiver.is_a?(Prism::SelfNode) ||
383
+ (receiver.is_a?(Prism::CallNode) && receiver.name == :class && receiver.receiver.is_a?(Prism::SelfNode))
384
+ end
385
+
386
+ # `Object.const_get` is the top level, which every lookup already reaches.
387
+ def constant_receiver?(receiver)
388
+ return false unless receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
389
+
390
+ as_written = Source::ConstantPath.qualified_name_or_nil(receiver)
391
+ !as_written.nil? && as_written.delete_prefix("::") != "Object"
392
+ end
393
+
394
+ def receiver_reference(receiver, nesting)
395
+ Reference.new(as_written: Source::ConstantPath.qualified_name_or_nil(receiver),
396
+ nesting: nesting.dup.freeze, from: nil, role: @role, path: @path,
397
+ line: receiver.location.start_line, rooted: Source::ConstantPath.rooted?(receiver))
398
+ end
399
+
400
+ # `[prefix, scope, within]` for a literal head under one anchor. A head ending in `::` names a
401
+ # namespace and reaches everything beneath it; any other head is the start of a name, so
402
+ # `"V#{version}"` reaches `V1_0` and `V2_2` alike, and only a string-prefix match says that. A rooted
403
+ # head ignores the receiver. `within` carries a constant receiver for {Graph} to resolve.
404
+ def bound(head, anchor)
405
+ anchor = TOP_LEVEL if head.start_with?("::")
406
+ head = head.delete_prefix("::")
407
+ scope = head.end_with?("::") ? :namespace : :leading
408
+ head = head.delete_suffix("::")
409
+ return nil if head.empty?
410
+
411
+ anchor == TOP_LEVEL ? [head, scope, nil] : [head, scope, anchor]
412
+ end
413
+
414
+ # The values a dynamic-resolution subject can hold: the subject itself, or for a local variable every
415
+ # value assigned to it in the enclosing local scope. A local with an assignment whose value cannot be
416
+ # read off the node (`+=`, a multiple assignment, `rescue => name`) stays a computed value.
417
+ #
418
+ # A name an enclosing block binds as a parameter or block-local (`|name|`, `|x; name|`) is that
419
+ # block's value, not the method's: reading it through the method's assignments named a literal the
420
+ # block never sees, and a constant only that literal named was hidden from the report.
421
+ def subjects(subject)
422
+ subject = pass_through(subject)
423
+ return [subject] unless subject.is_a?(Prism::LocalVariableReadNode) && @local_scope
424
+ return [subject] if @block_params.include?(subject.name)
425
+
426
+ (LocalValues.values(@local_scope, subject.name) || [subject]).map { |value| pass_through(value) }
427
+ end
428
+
429
+ # The string or symbol under `"V#{v}".freeze` / `.to_sym`; any other node unchanged.
430
+ def pass_through(node)
431
+ while node.is_a?(Prism::CallNode) && PASS_THROUGH.include?(node.name) && node.arguments.nil? &&
432
+ node.block.nil? && SUBJECT_SHAPES.key?(pass_through(node.receiver).class)
433
+ node = node.receiver
434
+ end
435
+ node
436
+ end
437
+
438
+ # `[exact name, literal head]` for a dynamic-resolution subject. A literal names its constant exactly;
439
+ # an interpolation can only bound the names its literal head starts; anything else bounds nothing.
440
+ def dynamic_target(subject)
441
+ case subject
442
+ when Prism::StringNode, Prism::SymbolNode then [Scan.usable_name(subject.unescaped), nil]
443
+ when Prism::InterpolatedStringNode, Prism::InterpolatedSymbolNode then [nil, literal_head(subject)]
444
+ else [nil, nil]
445
+ end
446
+ end
447
+
448
+ # The literal head of an interpolated name, as written: `"Foo::Bar::#{k}"` gives `Foo::Bar::`. Returns
449
+ # nil when the interpolation starts the string, which bounds nothing.
450
+ def literal_head(node)
451
+ head = node.parts.first
452
+ return nil unless head.is_a?(Prism::StringNode)
453
+
454
+ literal = Scan.usable_name(head.unescaped)
455
+ literal.nil? || literal.empty? ? nil : literal
456
+ end
457
+ end
458
+
104
459
  # Single-pass walker. Tracks `nesting` as the stack of enclosing declaration names.
105
460
  class Walker
461
+ include DynamicSites
462
+
106
463
  attr_reader :declarations, :references, :dynamic_uses
107
464
 
108
465
  def initialize(path:, role:)
@@ -111,6 +468,25 @@ module Rigor
111
468
  @declarations = []
112
469
  @references = []
113
470
  @dynamic_uses = []
471
+ # The declaration a meta-new rvalue is being walked for, or nil. See {#walk_constant_write}.
472
+ @owner = nil
473
+ # The node whose subtree holds the local variables in scope: the file, a method, or a class body.
474
+ @local_scope = nil
475
+ # Names an enclosing block's parameters or block-locals shadow within @local_scope.
476
+ @block_params = Set.new.freeze
477
+ # Whether `self` here is something other than the enclosing declaration — inside an `included do`,
478
+ # `class_eval` or anonymous `Class.new` block. See {#rebinds_self?}.
479
+ @self_rebound = false
480
+ @rebinding_blocks = Set.new.compare_by_identity
481
+ # What `self` is here, for {#self_lookup}: `:declaration` in a class or module body (and at the top
482
+ # level), `:singleton_method` in a `def self.x` or a def in a `class << self` body,
483
+ # `:instance_method` in any other def, `:singleton_class` in a `class << self` body outside its
484
+ # methods, and `:unknown` where the walk cannot name it.
485
+ @self_kind = :declaration
486
+ # Whether the walk is inside a block, whose `self` it cannot name. See {#self_lookup}.
487
+ @in_block = false
488
+ # Whether the innermost declaration is a module, whose instance methods' `self.class` is not it.
489
+ @module_body = false
114
490
  end
115
491
 
116
492
  def walk(node, nesting)
@@ -118,19 +494,27 @@ module Rigor
118
494
 
119
495
  case node
120
496
  when Prism::ClassNode then return walk_declaration(node, nesting, superclass: node.superclass)
121
- when Prism::ModuleNode then return walk_declaration(node, nesting, superclass: nil)
497
+ when Prism::ModuleNode then return walk_declaration(node, nesting, superclass: nil, module_body: true)
122
498
  when Prism::ConstantReadNode, Prism::ConstantPathNode
123
499
  record_reference(node, nesting)
124
500
  # A constant path's segments are not separate references — `A::B::C` is one reference to the leaf,
125
501
  # and descending would record `A` and `A::B` as references in their own right (22 spurious
126
502
  # candidates on Rigor's own lib came from exactly that in the #345 probe).
503
+ #
504
+ # A path on a computed base is the exception: `Migration[2.2]::MigrationRecord` names no constant
505
+ # this reading can resolve, but its base is a call whose receiver and arguments do.
506
+ walk(node.parent, nesting) if node.is_a?(Prism::ConstantPathNode) &&
507
+ Source::ConstantPath.qualified_name_or_nil(node).nil?
127
508
  return
128
509
  when Prism::ConstantWriteNode
129
- record_meta_new(node, nesting)
130
- walk(node.value, nesting)
510
+ walk_constant_write(node, nesting)
131
511
  return
132
512
  when Prism::CallNode
133
- record_dynamic_use(node)
513
+ record_call(node, nesting)
514
+ when Prism::ProgramNode, Prism::DefNode, Prism::SingletonClassNode
515
+ return in_local_scope(node) { node.rigor_each_child { |child| walk(child, nesting) } }
516
+ when Prism::BlockNode, Prism::LambdaNode
517
+ return in_block(node) { node.rigor_each_child { |child| walk(child, nesting) } }
134
518
  end
135
519
 
136
520
  node.rigor_each_child { |child| walk(child, nesting) }
@@ -138,28 +522,59 @@ module Rigor
138
522
 
139
523
  private
140
524
 
141
- def walk_declaration(node, nesting, superclass:)
525
+ def walk_declaration(node, nesting, superclass:, module_body: false)
142
526
  name = Source::ConstantPath.qualified_name(node.constant_path)
143
527
  return node.rigor_each_child { |child| walk(child, nesting) } if name.nil?
144
528
 
145
529
  fqn = (nesting + [name]).join("::")
146
530
  # 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
531
+ # the OUTER nesting, not inside the body being opened. It is part of `Sub`'s declaration, though, so
532
+ # it is credited to `Sub` (#1720): crediting the enclosing scope left a base nested in a module that
533
+ # is never itself reached unreachable, and rooted a top-level base even when every subclass is dead.
534
+ #
535
+ # The superclass is WALKED, not only read as a name: `< DelegateClass(Foo)`, `< Struct.new(:a,
536
+ # Foo::X)` and `< ActiveRecord::Migration[7.1]` are calls whose receiver and arguments name
537
+ # constants, and reading only a constant node recorded none of them, so `Foo` was a false candidate
538
+ # (#1733). Its references take the credit a meta-new rvalue's do.
539
+ walk_owned(superclass, nesting, fqn) if superclass
149
540
  includes = node.body ? mixin_names(node.body) : []
150
541
  @declarations << Declaration.new(fqn: fqn, path: @path, line: node.location.start_line,
151
542
  superclass: superclass && Source::ConstantPath.qualified_name(superclass),
152
543
  includes: includes.freeze)
153
- walk(node.body, nesting + [name]) if node.body
544
+ return unless node.body
545
+
546
+ # The body's nesting names the declaration itself, so a credit inherited from a meta-new rvalue
547
+ # this declaration sits in must not leak into it.
548
+ owner = @owner
549
+ saved = [@self_rebound, @in_block, @module_body]
550
+ @owner = nil
551
+ @self_rebound = @in_block = false
552
+ @module_body = module_body
553
+ in_local_scope(node.body, :declaration) { walk(node.body, nesting + [name]) }
554
+ @owner = owner
555
+ @self_rebound, @in_block, @module_body = saved
154
556
  end
155
557
 
156
- # `Const = Class.new` / `Module.new` / `Data.define(...)` / `Struct.new(...)` declare a class under a
157
- # constant, and `ScopeIndexer#record_class_new_constant_decl` already treats them as class declarations
158
- # for cross-file resolution. The report must agree, or every such constant reads as an unreferenced
159
- # value rather than a class.
160
- META_NEW = { "Class" => :new, "Module" => :new, "Data" => :define, "Struct" => :new }.freeze
161
- private_constant :META_NEW
558
+ # A meta-new rvalue — `Class.new(Base) { ... }`, `Struct.new(:a) do ... end` — is the declaration's
559
+ # header and body, so the references in it are credited to the declared constant, exactly as a
560
+ # `class Sub < Base` superclass is (#1720). Its lexical nesting is unchanged: a block opens no cref.
561
+ def walk_constant_write(node, nesting)
562
+ fqn = record_meta_new(node, nesting)
563
+ return walk(node.value, nesting) if fqn.nil?
564
+
565
+ walk_owned(node.value, nesting, fqn)
566
+ end
567
+
568
+ # Walks a declaration's header expression in the outer `nesting` it is evaluated in, crediting the
569
+ # references in it to the declared `fqn`.
570
+ def walk_owned(node, nesting, fqn)
571
+ owner = @owner
572
+ @owner = fqn
573
+ walk(node, nesting)
574
+ @owner = owner
575
+ end
162
576
 
577
+ # @return the declared FQN, or nil when the rvalue is not a meta-new form.
163
578
  def record_meta_new(node, nesting)
164
579
  call = node.value
165
580
  return unless call.is_a?(Prism::CallNode)
@@ -167,8 +582,10 @@ module Rigor
167
582
  recv = call.receiver
168
583
  return unless recv.is_a?(Prism::ConstantReadNode) && META_NEW[recv.name.to_s] == call.name
169
584
 
170
- @declarations << Declaration.new(fqn: (nesting + [node.name.to_s]).join("::"), path: @path,
585
+ fqn = (nesting + [node.name.to_s]).join("::")
586
+ @declarations << Declaration.new(fqn: fqn, path: @path,
171
587
  line: node.location.start_line, superclass: nil, includes: [].freeze)
588
+ fqn
172
589
  end
173
590
 
174
591
  # `include` / `prepend` / `extend` argument names written directly in the declaration body. Only the
@@ -184,73 +601,69 @@ module Rigor
184
601
  end
185
602
  end
186
603
 
187
- # Names that turn a String into a constant. `constantize` / `safe_constantize` are ActiveSupport;
188
- # `const_get` is core and may carry an explicit receiver (`Object.const_get`, `self.class.const_get`).
189
- DYNAMIC_RESOLVERS = %i[constantize safe_constantize const_get].freeze
190
- private_constant :DYNAMIC_RESOLVERS
191
-
192
- SUBJECT_SHAPES = {
193
- Prism::StringNode => "a literal string",
194
- Prism::SymbolNode => "a literal symbol",
195
- Prism::InterpolatedStringNode => "an interpolated string"
196
- }.freeze
197
- private_constant :SUBJECT_SHAPES
198
-
199
- # Rigor knows the argument's shape, which is the whole reason this can be tiered rather than treated
200
- # 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)
203
- return unless DYNAMIC_RESOLVERS.include?(node.name)
204
-
205
- subject = node.name == :const_get ? node.arguments&.arguments&.first : node.receiver
206
- return if subject.nil?
207
-
208
- name, prefix = dynamic_target(subject)
209
- # A literal whose bytes are not valid UTF-8 cannot name a constant; dropping it is the only safe
210
- # answer, and carrying it forward crashed the whole run downstream.
211
- return if subject.is_a?(Prism::StringNode) && name.nil?
212
- return if subject.is_a?(Prism::SymbolNode) && name.nil?
213
-
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
- )
604
+ def in_local_scope(scope, self_kind = self_kind_in(scope))
605
+ saved = [@local_scope, @block_params, @self_kind]
606
+ @local_scope = scope
607
+ @block_params = Set.new.freeze
608
+ @self_kind = self_kind
609
+ yield
610
+ ensure
611
+ @local_scope, @block_params, @self_kind = saved
218
612
  end
219
613
 
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.
223
- def dynamic_target(subject)
224
- case subject
225
- when Prism::StringNode, Prism::SymbolNode then [Scan.usable_name(subject.unescaped), nil]
226
- when Prism::InterpolatedStringNode then [nil, literal_prefix(subject)]
227
- else [nil, nil]
614
+ # The {#self_lookup} kind inside `scope`, from the kind around it. Only a receiver-less def or a `def
615
+ # self.x` directly in a declaration body, and a `class << self` there, keep `self` nameable.
616
+ def self_kind_in(scope)
617
+ case scope
618
+ when Prism::ProgramNode then :declaration
619
+ when Prism::SingletonClassNode
620
+ scope.expression.is_a?(Prism::SelfNode) && @self_kind == :declaration ? :singleton_class : :unknown
621
+ when Prism::DefNode then def_self_kind(scope)
622
+ else :unknown
228
623
  end
229
624
  end
230
625
 
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
- def site(node)
234
- "#{@path}:#{node.location.start_line}"
626
+ # A receiver-less def names its `self` only directly in a class body or a `class << self` body. In a
627
+ # module body `self.class` is the includer's class, or `Module` under `module_function` / `extend
628
+ # self`; a def nested in another def lands on whatever that method's cref is, which the walk cannot
629
+ # tell apart.
630
+ def def_self_kind(node)
631
+ if node.receiver.nil?
632
+ case @self_kind
633
+ when :singleton_class then :singleton_method
634
+ when :declaration then @module_body ? :unknown : :instance_method
635
+ else :unknown
636
+ end
637
+ elsif node.receiver.is_a?(Prism::SelfNode) && @self_kind == :declaration
638
+ :singleton_method
639
+ else
640
+ :unknown
641
+ end
235
642
  end
236
643
 
237
- def literal_prefix(node)
238
- head = node.parts.first
239
- return nil unless head.is_a?(Prism::StringNode)
240
-
241
- literal = Scan.usable_name(head.unescaped)
242
- return nil if literal.nil?
644
+ def in_block(block)
645
+ saved = [@block_params, @self_rebound, @in_block]
646
+ names = block.parameters ? LocalValues.bindings(block.parameters) : []
647
+ @block_params = (@block_params | names).freeze unless names.empty?
648
+ @self_rebound ||= @rebinding_blocks.include?(block)
649
+ @in_block = true
650
+ yield
651
+ ensure
652
+ @block_params, @self_rebound, @in_block = saved
653
+ end
243
654
 
244
- trimmed = literal.sub(/::\z/, "")
245
- trimmed.empty? ? nil : trimmed
655
+ def site(node)
656
+ "#{@path}:#{node.location.start_line}"
246
657
  end
247
658
 
248
- def record_reference(node, nesting)
659
+ # `from` defaults to the innermost enclosing declaration; a declaration's own header (its superclass,
660
+ # its meta-new rvalue) is credited to that declaration instead.
661
+ def record_reference(node, nesting, from: @owner)
249
662
  as_written = Source::ConstantPath.qualified_name_or_nil(node)
250
663
  return if as_written.nil?
251
664
 
252
- @references << Reference.new(as_written: as_written, nesting: nesting.dup.freeze,
253
- from: nesting.empty? ? nil : nesting.join("::"),
665
+ from ||= nesting.empty? ? nil : nesting.join("::")
666
+ @references << Reference.new(as_written: as_written, nesting: nesting.dup.freeze, from: from,
254
667
  role: @role, path: @path, line: node.location.start_line,
255
668
  rooted: Source::ConstantPath.rooted?(node))
256
669
  end