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
@@ -36,12 +36,55 @@ module Rigor
36
36
  # The probe is ALWAYS sequential and independent of the analysis's pool mode, so the invalidation decision
37
37
  # is identical whether the recheck ran pooled or sequential (the pooled-vs-sequential parity requirement).
38
38
  class PluginFactFingerprint
39
- # `digest` is nil when no plugins are loaded — the fast path (nothing to fingerprint), which reuses
40
- # freely. `opaque_plugin_ids` names contributing-but-surfaceless plugins (see the class doc); when
41
- # non-empty the snapshot is never reused.
42
- Result = Data.define(:digest, :opaque_plugin_ids) do
39
+ # Why one plugin is opaque to the fingerprint. `kind` is one of:
40
+ # :no_surface the plugin contributes types and declares no fact, producer or hook;
41
+ # :undigestible a value it declares or publishes cannot be digested (a Proc, an IO, ...);
42
+ # :raised the hook or a producer raised while its value was being read.
43
+ # `input` names the input: `"producer worker_index"`, `"fact callback"`, `"hook incremental_state_fingerprint"`.
44
+ # `class_name` is the class of the value that could not be digested, or of the exception raised; an anonymous
45
+ # class reads "anonymous Class", and nil means the class itself could not be read.
46
+ OpaqueReason = Data.define(:kind, :input, :class_name) do
47
+ # The clause a note states about plugin `id`. The note groups :no_surface plugins itself, so this is only
48
+ # called for :undigestible and :raised reasons.
49
+ def describe(id)
50
+ return "plugin #{id}'s #{input} raised #{class_name}" if kind == :raised
51
+
52
+ "plugin #{id}'s #{input} has a value the incremental fingerprint cannot digest#{parenthesised_class}"
53
+ end
54
+
55
+ # The same verdict without the plugin id, for a `--cache-stats` line that already names it.
56
+ def summary
57
+ return "no incremental fingerprint surface" if kind == :no_surface
58
+ return "#{input} raised #{class_name}" if kind == :raised
59
+
60
+ "#{input} has a value the incremental fingerprint cannot digest#{parenthesised_class}"
61
+ end
62
+
63
+ private
64
+
65
+ def parenthesised_class
66
+ return "" if class_name.nil?
67
+
68
+ " (#{article(class_name)} #{class_name})"
69
+ end
70
+
71
+ def article(word)
72
+ word.match?(/\A[aeiou]/i) ? "an" : "a"
73
+ end
74
+ end
75
+
76
+ # `digest` is nil when no plugins are loaded — the fast path (nothing to fingerprint), which reuses freely.
77
+ # `opaque_reasons` maps each opaque plugin id to its {OpaqueReason}, sorted by id. `fact_store_unreadable` is
78
+ # true when the fact store could not be read at all: no single plugin is to blame, so no reason names one.
79
+ # Either makes the snapshot un-reusable.
80
+ Result = Data.define(:digest, :opaque_reasons, :fact_store_unreadable) do
81
+ # The opaque plugin ids, sorted (the keys of {#opaque_reasons}).
82
+ def opaque_plugin_ids
83
+ opaque_reasons.keys
84
+ end
85
+
43
86
  def opaque?
44
- !opaque_plugin_ids.empty?
87
+ fact_store_unreadable || !opaque_reasons.empty?
45
88
  end
46
89
 
47
90
  # A warm recheck may reuse the snapshot only when this run is not opaque and its fact-surface digest
@@ -97,16 +140,20 @@ module Rigor
97
140
  end
98
141
 
99
142
  def digest_registry(registry)
100
- return Result.new(digest: nil, opaque_plugin_ids: [].freeze) if registry.nil? || registry.empty?
143
+ empty = registry.nil? || registry.empty?
144
+ return Result.new(digest: nil, opaque_reasons: {}.freeze, fact_store_unreadable: false) if empty
101
145
 
102
- opaque = []
103
- facts_by_plugin = facts_by_plugin(registry, opaque)
104
- parts = fact_parts(facts_by_plugin)
105
- registry.plugins.each { |plugin| collect_plugin_parts(plugin, facts_by_plugin, parts, opaque) }
106
- # Sort the parts so the digest is independent of plugin registration / iteration order.
146
+ reasons = {}
147
+ facts, store_unreadable = facts_by_plugin(registry, reasons)
148
+ parts = fact_parts(facts)
149
+ registry.plugins.each { |plugin| collect_plugin_parts(plugin, facts, parts, reasons) }
150
+ # Sort the parts so the digest is independent of plugin registration / iteration order. An unreadable
151
+ # store names no plugin: the snapshot is opaque as a whole, and per-plugin reasons would blame plugins
152
+ # whose facts were simply not read.
107
153
  Result.new(
108
154
  digest: Digest::SHA256.hexdigest(parts.sort.join("\x00")),
109
- opaque_plugin_ids: opaque.uniq.freeze
155
+ opaque_reasons: (store_unreadable ? {} : reasons.sort.to_h).freeze,
156
+ fact_store_unreadable: store_unreadable
110
157
  )
111
158
  end
112
159
 
@@ -119,23 +166,25 @@ module Rigor
119
166
 
120
167
  # Appends one plugin's producer / hook parts to `parts` and marks it opaque when it contributes a type
121
168
  # (`dynamic_return` / `narrowing_facts`) yet declares NONE of (a)/(b)/(c).
122
- def collect_plugin_parts(plugin, facts_by_plugin, parts, opaque)
169
+ def collect_plugin_parts(plugin, facts_by_plugin, parts, reasons)
123
170
  id = safe_id(plugin)
124
171
  has_facts = facts_by_plugin.key?(id)
125
- has_producers = collect_producer_parts(plugin, id, parts, opaque)
126
- has_hook = collect_hook_part(plugin, id, parts, opaque)
172
+ has_producers = collect_producer_parts(plugin, id, parts, reasons)
173
+ has_hook = collect_hook_part(plugin, id, parts, reasons)
127
174
  surface = has_facts || has_producers || has_hook
128
- opaque << id if !surface && contributes_types?(plugin)
175
+ return if surface || !contributes_types?(plugin)
176
+
177
+ mark_opaque(reasons, id, OpaqueReason.new(kind: :no_surface, input: nil, class_name: nil))
129
178
  end
130
179
 
131
- # `{ plugin_id => ["name=digest", ...] }` for every ADR-9 published fact (channel a). A plugin that
132
- # published nothing does not appear.
180
+ # `[{ plugin_id => ["name=digest", ...] }, unreadable]` for every ADR-9 published fact (channel a). A plugin
181
+ # that published nothing does not appear.
133
182
  #
134
- # A fact the fingerprint cannot digest is an input it cannot see, so its publisher is opaque, whether or
135
- # not the publisher contributes a type itself: other plugins read its facts. A fact store that cannot be
136
- # read at all makes every plugin opaque. Both force the full run; dropping the facts instead would let a
137
- # plugin with another surface reuse a snapshot its facts no longer match.
138
- def facts_by_plugin(registry, opaque)
183
+ # A fact the fingerprint cannot digest is an input it cannot see, so its publisher is opaque, whether or not
184
+ # the publisher contributes a type itself: other plugins read its facts. A fact store that cannot be read at
185
+ # all makes the whole surface opaque. Both force the full run; dropping the facts instead would let a plugin
186
+ # with another surface reuse a snapshot its facts no longer match.
187
+ def facts_by_plugin(registry, reasons)
139
188
  result = Hash.new { |hash, key| hash[key] = [] }
140
189
  fact_store = registry.plugins.first&.services&.fact_store
141
190
  fact_store&.each_fact do |fact|
@@ -143,13 +192,12 @@ module Rigor
143
192
  begin
144
193
  result[plugin_id] << "#{fact.name}=#{digest_value(fact.value)}"
145
194
  rescue StandardError
146
- opaque << plugin_id
195
+ mark_opaque(reasons, plugin_id, undigestible("fact #{fact.name}", fact.value))
147
196
  end
148
197
  end
149
- result
198
+ [result, false]
150
199
  rescue StandardError
151
- registry.plugins.each { |plugin| opaque << safe_id(plugin) }
152
- Hash.new { |hash, key| hash[key] = [] }
200
+ [Hash.new { |hash, key| hash[key] = [] }, true]
153
201
  end
154
202
 
155
203
  # Channel (b): a stable signature of every declared producer's VALUE — a digest of the producer's own
@@ -161,28 +209,67 @@ module Rigor
161
209
  # a value-preserving edit keeps the recheck incremental. Returns true when the plugin declared at least
162
210
  # one producer (a surface); a producer whose value cannot be digested marks the plugin opaque.
163
211
  # rubocop:disable-next Naming/PredicateMethod -- reports has-surface AND appends parts (a side-effecting builder)
164
- def collect_producer_parts(plugin, id, parts, opaque)
212
+ def collect_producer_parts(plugin, id, parts, reasons)
165
213
  producer_ids = plugin.class.producers.keys
166
214
  return false if producer_ids.empty?
167
215
 
168
216
  producer_ids.sort.each do |producer_id|
169
- parts << "prod\x1f#{id}\x1f#{producer_id}\x1f#{digest_value(plugin.producer_value(producer_id))}"
170
- rescue StandardError
171
- opaque << id
217
+ input = "producer #{producer_id}"
218
+ begin
219
+ value = plugin.producer_value(producer_id)
220
+ rescue StandardError => e
221
+ mark_opaque(reasons, id, raised(input, e))
222
+ next
223
+ end
224
+ digest_part(parts, reasons, id, input, "prod\x1f#{id}\x1f#{producer_id}", value)
172
225
  end
173
226
  true
174
227
  end
175
228
 
176
- # Channel (c): the optional {Plugin::Base#incremental_state_fingerprint} hook. Returns true when the
177
- # plugin defines it (it has a surface).
178
- def collect_hook_part(plugin, id, parts, opaque)
229
+ # Channel (c): the optional {Plugin::Base#incremental_state_fingerprint} hook. Returns true when the plugin
230
+ # defines it (it has a surface).
231
+ # rubocop:disable-next Naming/PredicateMethod -- reports has-surface AND appends parts (a side-effecting builder)
232
+ def collect_hook_part(plugin, id, parts, reasons)
179
233
  return false unless plugin.respond_to?(:incremental_state_fingerprint)
180
234
 
181
- parts << "hook\x1f#{id}\x1f#{digest_value(plugin.incremental_state_fingerprint)}"
235
+ input = "hook incremental_state_fingerprint"
236
+ begin
237
+ value = plugin.incremental_state_fingerprint
238
+ rescue StandardError => e
239
+ mark_opaque(reasons, id, raised(input, e))
240
+ return true
241
+ end
242
+ digest_part(parts, reasons, id, input, "hook\x1f#{id}", value)
182
243
  true
244
+ end
245
+
246
+ # Appends `prefix\x1f<digest of value>` to `parts`, or marks the plugin opaque when the value cannot be digested.
247
+ def digest_part(parts, reasons, id, input, prefix, value)
248
+ parts << "#{prefix}\x1f#{digest_value(value)}"
183
249
  rescue StandardError
184
- opaque << id
185
- true
250
+ mark_opaque(reasons, id, undigestible(input, value))
251
+ end
252
+
253
+ # The first reason a plugin is found opaque wins: one reason per plugin id, as {Result#opaque_reasons} states.
254
+ def mark_opaque(reasons, id, reason)
255
+ reasons[id] ||= reason
256
+ end
257
+
258
+ def undigestible(input, value)
259
+ OpaqueReason.new(kind: :undigestible, input: input, class_name: class_name_of(value))
260
+ end
261
+
262
+ def raised(input, error)
263
+ OpaqueReason.new(kind: :raised, input: input, class_name: class_name_of(error))
264
+ end
265
+
266
+ # The name of `object`'s class, "anonymous Class" for an anonymous one, or nil when even the class is unreadable
267
+ # (a BasicObject has no `#class`).
268
+ def class_name_of(object)
269
+ klass = object.class
270
+ klass.name || "anonymous Class"
271
+ rescue StandardError
272
+ nil
186
273
  end
187
274
 
188
275
  # A digest of a fact / producer / hook value that depends on the value alone (issue #1574). A producer's
@@ -5,6 +5,121 @@ require_relative "scan"
5
5
  module Rigor
6
6
  module Analysis
7
7
  module Reachability
8
+ # ADR-102 WD4 — how {Graph} reads a `constantize` / `const_get` site: a literal name becomes a reference
9
+ # looked up where Ruby looks it up, and an interpolated one on a known receiver is anchored there.
10
+ module DynamicLookup
11
+ private
12
+
13
+ # A literal-argument `"Foo::Bar".constantize` names its constant exactly, so it is a REFERENCE, not an
14
+ # unknown. Keeping this distinct from {Graph#tainted} is what stops the tier being a blanket namespace
15
+ # poison — Rigor knows the argument's shape, and a type-free indexer does not.
16
+ #
17
+ # A literal `const_get` on a known receiver is looked up where Ruby looks it up (#1761): the receiver,
18
+ # then its ancestors unless the call passes `inherit = false`, then the top level unless it does. A
19
+ # receiver holding the name is the answer — resolving it anywhere else too would make a dead same-named
20
+ # constant reachable; past the receiver see {#literal_targets}. A receiver that does not resolve as a
21
+ # whole is unknown — `Outer::Missing` peeled to `Outer` may alias anything, and `const_get` never
22
+ # searches `Outer` — so it falls back to the top level as an unknown receiver does.
23
+ #
24
+ # The reference carries the referring file's role (WD8): a spec's `const_get("Foo")` keeps `Foo`
25
+ # test-reachable, as its written `Foo` would, rather than making it live in production.
26
+ def literal_dynamic_references(dynamic_uses)
27
+ dynamic_uses.flat_map do |use|
28
+ next [] if use.name.nil?
29
+
30
+ name = use.name.delete_prefix("::")
31
+ targets = use.within ? literal_targets(use, name) : [nil]
32
+ targets.map do |target|
33
+ Scan::Reference.new(as_written: target || name, nesting: [].freeze, from: nil,
34
+ role: use.role, path: use.path, line: use.line,
35
+ rooted: !target.nil? || use.name.start_with?("::"))
36
+ end
37
+ end
38
+ end
39
+
40
+ # The declarations a literal `name` can name when looked up from `use.within`, a member's owner peeled
41
+ # as {#resolve} peels it; nil stands for the top level. The receiver declaring it is the answer. Past
42
+ # the receiver the walk only approximates Ruby: an ancestor named as written may resolve elsewhere
43
+ # (`class Api::Thing < Base` inherits `::Base`, not `Api::Base`), so every scope an ancestor could be
44
+ # that declares the name is kept, and the top level with them. Keeping a dead constant reachable is
45
+ # the cheaper mistake than listing a live one. A receiver that does not resolve is the top level; with
46
+ # `inherit = false` a resolved receiver that does not declare the name is nothing (Ruby raises
47
+ # `NameError` there).
48
+ def literal_targets(use, name)
49
+ receiver = use.receiver { |ref| resolve_ref(ref) }
50
+ return [nil] if receiver.nil?
51
+
52
+ local = @shadows[use.path]
53
+ own = declared_member(receiver, name, local)
54
+ return [own] if own
55
+ return [] unless use.inherit
56
+
57
+ plausible_ancestors(receiver).filter_map { |scope| declared_member(scope, name, local) }.uniq + [nil]
58
+ end
59
+
60
+ # `scope::name`, or the longest declared prefix of it, when one is declared.
61
+ def declared_member(scope, name, local)
62
+ segments = name.split("::")
63
+ segments.length.downto(1) do |count|
64
+ candidate = "#{scope}::#{segments.first(count).join('::')}"
65
+ return candidate if declared?(candidate, nil, local)
66
+ end
67
+ nil
68
+ end
69
+
70
+ # Every declaration an ancestor of `fqn` could be: each as-written superclass or mixin resolved at
71
+ # every enclosing scope that declares it, not only the innermost, and transitively.
72
+ def plausible_ancestors(fqn)
73
+ (@plausible_ancestors ||= {})[fqn] ||= plausible_ancestor_walk(fqn)
74
+ end
75
+
76
+ def plausible_ancestor_walk(fqn)
77
+ seen = Set[fqn]
78
+ queue = [fqn]
79
+ out = []
80
+ until queue.empty?
81
+ current = queue.shift
82
+ @by_fqn.fetch(current, []).each do |decl|
83
+ (decl.includes + [decl.superclass]).compact.each do |raw|
84
+ plausible_resolutions(current, raw).each do |resolved|
85
+ next if seen.include?(resolved)
86
+
87
+ seen << resolved
88
+ out << resolved
89
+ queue << resolved
90
+ end
91
+ end
92
+ end
93
+ end
94
+ out.freeze
95
+ end
96
+
97
+ def plausible_resolutions(subclass_fqn, raw)
98
+ segments = subclass_fqn.split("::")
99
+ (segments.length - 1).downto(0).filter_map do |i|
100
+ candidate = (segments[0, i] + [raw]).join("::")
101
+ candidate if candidate != subclass_fqn && @by_fqn.key?(candidate)
102
+ end
103
+ end
104
+
105
+ # The namespaces a `const_get` on `anchors` searches before the top level: the receiver, then, with the
106
+ # default `inherit = true`, every scope its superclass chain and mixins could be (#1761) — a taint is
107
+ # safe to over-reach.
108
+ def receiver_scopes(use, anchors)
109
+ return anchors unless use.inherit
110
+
111
+ anchors.flat_map { |anchor| [anchor, *plausible_ancestors(anchor)] }.uniq
112
+ end
113
+
114
+ # An interpolated site on a known receiver, with the receiver resolved into absolute `prefix`es.
115
+ def anchored(use)
116
+ return [use] if use.within.nil? || use.prefix.nil?
117
+
118
+ anchors = use.anchors { |ref| resolve_ref(ref) }
119
+ receiver_scopes(use, anchors).map { |scope| use.with(prefix: "#{scope}::#{use.prefix}", within: nil) }
120
+ end
121
+ end
122
+
8
123
  # ADR-102 — the cross-file half: resolves every as-written reference to a declaration, then marks from the
9
124
  # root set. Pure data; no engine coupling, so `rigor check`'s diagnostic stream is untouched by
10
125
  # construction rather than by a gate (WD1).
@@ -15,6 +130,8 @@ module Rigor
15
130
  # believe in. The two walks are separate implementations (this one is name-level and needs no types), so
16
131
  # `spec/rigor/analysis/reachability/graph_spec.rb` pins them to the same answers on shared fixtures.
17
132
  class Graph
133
+ include DynamicLookup
134
+
18
135
  Candidate = Data.define(:fqn, :path, :line)
19
136
 
20
137
  # `test_only` is its own list, not a flag on `candidates`: a candidate is by definition unreachable, so
@@ -35,15 +152,21 @@ module Rigor
35
152
  # a reopened gem or stdlib class must never be a candidate (WD6). Defaults to "nothing is foreign".
36
153
  # @param dynamic_uses — sites where a constant is reached by name at runtime.
37
154
  # A literal-argument site contributes a real reference; a dynamic one taints a namespace (WD4).
38
- def initialize(declarations:, references:, root_fqns: [], dynamic_uses: [], foreign: ->(_fqn) { false })
155
+ # @param shadows — declarations made in files outside the analysed paths. They are never nodes, but a
156
+ # reference written in the SAME file resolves to them first, as Ruby would (#1732, see {#resolve_ref}).
157
+ def initialize(declarations:, references:, root_fqns: [], dynamic_uses: [], shadows: [],
158
+ foreign: ->(_fqn) { false })
39
159
  @declarations = declarations
40
- @dynamic_uses = dynamic_uses
41
- @references = references + literal_dynamic_references(dynamic_uses)
42
160
  @root_fqns = root_fqns.to_set
43
161
  @foreign = foreign
44
162
  @by_fqn = declarations.group_by(&:fqn)
163
+ @shadows = shadows.group_by(&:path).transform_values { |decls| decls.to_set(&:fqn) }
164
+ @shadows.default = Set.new.freeze
45
165
  @owned = @by_fqn.keys.reject { |fqn| @foreign.call(fqn) }.to_set
46
166
  @ancestors = {}
167
+ @references = references + literal_dynamic_references(dynamic_uses)
168
+ # `Foo.const_get("V#{k}")` bounds its reach by `Foo`, a reference like any other (#1734).
169
+ @dynamic_uses = dynamic_uses.flat_map { |use| anchored(use) }
47
170
  end
48
171
 
49
172
  def report
@@ -51,39 +174,36 @@ module Rigor
51
174
  production = walk(edges, seeds: production_seeds, roles: %i[production task config])
52
175
  reachable = walk(edges, seeds: production_seeds | test_seeds, roles: %i[production task config test])
53
176
  unreached = @owned - reachable
54
- namespaces = namespace_only(unreached, reachable)
177
+ # A namespace is excused by a member PRODUCTION reaches. One whose members only tests reach is dead
178
+ # production code like them, so it joins them under test-only rather than vanishing from every
179
+ # bucket (#1732).
180
+ namespaces = namespace_only(unreached, production)
181
+ test_only = (reachable - production) | namespace_only(unreached - namespaces, reachable)
182
+ unreached -= namespaces | test_only
183
+ # A namespace a test names (`Ns::CONST`) while production reaches a declaration under it is live in
184
+ # production for the same reason an unreached one is not dead (#1732).
185
+ live_namespaces = namespace_only(test_only, production)
186
+ test_only -= live_namespaces
55
187
  # The data-file demotion applies to BOTH buckets it can speak to, which is what the tier
56
188
  # contract says and what the implementation had narrowed (#370). See {#tainted}.
57
- test_only = reachable - production
58
- undecidable = tainted(unreached - namespaces).merge(tainted(test_only))
59
- build_report(edges: edges, reachable: reachable, unreached: unreached, namespaces: namespaces,
60
- test_only: test_only, undecidable: undecidable)
189
+ undecidable = tainted(unreached).merge(tainted(test_only))
190
+ undecidable = spread_undecidable(edges, undecidable, unreached | test_only,
191
+ through: namespaces | live_namespaces)
192
+ build_report(edges:, reachable:, unreached:, namespaces:, test_only:, undecidable:)
61
193
  end
62
194
 
63
195
  private
64
196
 
65
197
  def build_report(edges:, reachable:, unreached:, namespaces:, test_only:, undecidable:)
66
198
  demoted = undecidable.keys.to_set
67
- Report.new(declared: @owned.size, reachable: reachable.size,
68
- candidates: rows(unreached - namespaces - demoted),
199
+ # A test-only namespace counts as reachable, like the members it is listed with.
200
+ Report.new(declared: @owned.size, reachable: (reachable | test_only).size,
201
+ candidates: rows(unreached - demoted),
69
202
  undecidable: undecidable.map { |fqn, reason| undecidable_row(fqn, reason) }.freeze,
70
203
  test_only: rows(test_only - demoted),
71
204
  namespaces: namespaces.size, roots: production_seeds.size, edges: edges.size)
72
205
  end
73
206
 
74
- # A literal-argument `"Foo::Bar".constantize` names its constant exactly, so it is a REFERENCE, not an
75
- # unknown. Keeping this distinct from the taint below is what stops the tier being a blanket namespace
76
- # poison — Rigor knows the argument's shape, and a type-free indexer does not.
77
- def literal_dynamic_references(dynamic_uses)
78
- dynamic_uses.filter_map do |use|
79
- next if use.name.nil?
80
-
81
- Scan::Reference.new(as_written: use.name.sub(/\A::/, ""), nesting: [].freeze, from: nil,
82
- role: :production, path: use.path, line: use.line,
83
- rooted: use.name.start_with?("::"))
84
- end
85
- end
86
-
87
207
  # `{fqn => reason}` for every declaration in `fqns` a dynamic site could still be naming. A site with a
88
208
  # literal prefix taints that namespace and everything under it; a site with no prefix at all cannot be
89
209
  # bounded, so it taints nothing rather than everything — poisoning the whole project would empty the
@@ -109,6 +229,41 @@ module Rigor
109
229
  end
110
230
  end
111
231
 
232
+ # Whatever an undecidable declaration reaches is undecidable too (#1720 review). `class Sub < Base` with
233
+ # `Sub` reachable through `"Sub#{x}".constantize` left `Base` a DEFINITE candidate, and acting on that row
234
+ # deletes the base of a class that may well be live. Only declarations that would otherwise be
235
+ # reported — unreached or test-only — are moved; the reason names the declaration they hang from.
236
+ #
237
+ # The spread passes `through` a declaration hidden as a namespace without listing it: a service hidden
238
+ # only because something names a constant nested in it (`Svc::Error`) still carries the undecidable
239
+ # evidence on to what its body names. Stopping there left the next service down under "reachable only
240
+ # from tests" (#1732).
241
+ def spread_undecidable(edges, undecidable, reportable, through:)
242
+ return undecidable if undecidable.empty?
243
+
244
+ out = Hash.new { |h, k| h[k] = [] }
245
+ edges.each { |from, to, _role| out[from] << to }
246
+ result = undecidable.dup
247
+ passed = Set.new
248
+ queue = undecidable.keys
249
+ until queue.empty?
250
+ from = queue.shift
251
+ out[from].each do |target|
252
+ next if result.key?(target) || passed.include?(target)
253
+
254
+ if reportable.include?(target)
255
+ result[target] = "reachable from #{from}, which cannot be decided"
256
+ elsif through.include?(target)
257
+ passed << target
258
+ else
259
+ next
260
+ end
261
+ queue << target
262
+ end
263
+ end
264
+ result
265
+ end
266
+
112
267
  def undecidable_row(fqn, reason)
113
268
  site = @by_fqn.fetch(fqn).first
114
269
  Undecidable.new(fqn: fqn, path: site.path, line: site.line, reason: reason)
@@ -132,14 +287,32 @@ module Rigor
132
287
  # Seeds that make a declaration live in PRODUCTION: named entry points, plus anything referenced at
133
288
  # file level by a non-test file (file-level code runs on load, so its target is live).
134
289
  def production_seeds
135
- @production_seeds ||= (@root_fqns & @owned) | seeds_from { |ref| ref.from.nil? && ref.role != :test }
290
+ @production_seeds ||= (@root_fqns & @owned) | seeds_from { |ref| source(ref).nil? && ref.role != :test }
136
291
  end
137
292
 
138
293
  # Seeds that make a declaration live only through TEST code. Kept separate from production seeds
139
294
  # rather than folded in: a spec's file-level `Foo.new` would otherwise promote `Foo` to a root and
140
295
  # erase the very distinction WD8 exists to report.
141
296
  def test_seeds
142
- @test_seeds ||= seeds_from { |ref| ref.from.nil? && ref.role == :test }
297
+ @test_seeds ||= seeds_from { |ref| source(ref).nil? && ref.role == :test }
298
+ end
299
+
300
+ # The node an edge leaves: the owned declaration whose code holds the reference, with a declaration-header
301
+ # reference (`class Sub < Base`) credited to `Sub` (#1720). Only an owned declaration from `paths:` is a
302
+ # node, though. A class an initializer, `config/application.rb` or a spec helper declares, or a reopened
303
+ # gem class (WD6), is outside the declaration set, so the walk can never start from it and an edge
304
+ # leaving it would silently drop the evidence. Falling back to the enclosing nesting (#1724) did not help
305
+ # either: `module MyApp; class Application` and a spec's `module Outer; class Fake < OBase` fall back to a
306
+ # module that is never reached itself, or is not a node at all (#1732).
307
+ #
308
+ # Such a reference counts as file-level code of its file instead, in that file's role: a spec helper's
309
+ # reference keeps its target test-reachable, an initializer's keeps it live in production. A class header
310
+ # and a class body's own statements run when the file loads; a method body is read the same way because
311
+ # whether it runs is not decidable from a declaration this report does not own, and the other reading
312
+ # would report live code as dead. What the reference NAMES is still resolved against its own file's
313
+ # declarations first (see {#resolve_ref}).
314
+ def source(ref)
315
+ @owned.include?(ref.from) ? ref.from : nil
143
316
  end
144
317
 
145
318
  def seeds_from
@@ -147,7 +320,7 @@ module Rigor
147
320
  @references.each do |ref|
148
321
  next unless yield(ref)
149
322
 
150
- target = resolve(ref.as_written, ref.nesting, rooted: ref.rooted)
323
+ target = resolve_ref(ref)
151
324
  set << target if target && @owned.include?(target)
152
325
  end
153
326
  set
@@ -156,14 +329,39 @@ module Rigor
156
329
  # `[from_fqn_or_nil, to_fqn, role]` for every reference that resolves to an owned declaration.
157
330
  def resolved_edges
158
331
  @resolved_edges ||= @references.filter_map do |ref|
159
- target = resolve(ref.as_written, ref.nesting, rooted: ref.rooted)
332
+ target = resolve_ref(ref)
160
333
  next unless target && @owned.include?(target)
161
- next if ref.from == target # a declaration referencing itself is not evidence of use
162
334
 
163
- [ref.from, target, ref.role]
335
+ from = source(ref)
336
+ next if from == target # a declaration referencing itself is not evidence of use
337
+
338
+ [from, target, ref.role]
164
339
  end
165
340
  end
166
341
 
342
+ # A declaration-header reference never names the declaration it belongs to: `module Api; class User <
343
+ # User; end; end` reads `User` before `Api::User` exists, so Ruby answers `::User`. Every other reference
344
+ # resolves as written, and one that lands on its own declaration is dropped as a self-reference.
345
+ #
346
+ # A class its own file declares outside `paths:` shadows a same-named owned one, as Ruby's lookup does:
347
+ # `LegacyThing` inside a migration's local `class LegacyThing < ActiveRecord::Base` stub names the stub,
348
+ # not the app's model. The answer is then not an owned node and the reference is dropped. Only the
349
+ # referring file's own declarations count: a spec support file's stub is loaded with the specs, never
350
+ # with production, so letting it shadow other files could hide a production reference (#1732).
351
+ def resolve_ref(ref)
352
+ local = @shadows[ref.path]
353
+ target = resolve(ref.as_written, ref.nesting, rooted: ref.rooted, local: local)
354
+ return target unless target && target == ref.from && header?(ref)
355
+
356
+ resolve(ref.as_written, ref.nesting, rooted: ref.rooted, exclude: target, local: local)
357
+ end
358
+
359
+ # Only a header reference is credited to something other than the scope it is written in (see
360
+ # {Scan::Reference}).
361
+ def header?(ref)
362
+ ref.from != (ref.nesting.empty? ? nil : ref.nesting.join("::"))
363
+ end
364
+
167
365
  # Mark-and-sweep, not reference counting: an edge only propagates if its SOURCE is itself reachable, so
168
366
  # a cluster of mutually-referencing dead classes stays dead (ADR-102 WD2).
169
367
  #
@@ -198,18 +396,19 @@ module Rigor
198
396
  # Ruby's constant lookup at name granularity: `Module.nesting` innermost first, then the ancestors of
199
397
  # the innermost cresting scope (#354), then the bare name. When `rooted: true`, lexical nesting and
200
398
  # ancestors are skipped, answering the top-level declaration directly (#625).
201
- def resolve(as_written, nesting, rooted: false)
399
+ # `exclude` names a declaration the answer must not be (see {#resolve_ref}).
400
+ def resolve(as_written, nesting, rooted: false, exclude: nil, local: nil)
202
401
  if rooted
203
- return as_written if @by_fqn.key?(as_written)
402
+ return as_written if declared?(as_written, exclude, local)
204
403
 
205
404
  idx = as_written.rindex("::")
206
- return idx ? resolve(as_written[0, idx], nesting, rooted: true) : nil
405
+ return idx ? resolve(as_written[0, idx], nesting, rooted: true, exclude: exclude, local: local) : nil
207
406
  end
208
407
 
209
408
  walker = nesting.dup
210
409
  until walker.empty?
211
410
  candidate = (walker + [as_written]).join("::")
212
- return candidate if @by_fqn.key?(candidate)
411
+ return candidate if declared?(candidate, exclude, local)
213
412
 
214
413
  walker.pop
215
414
  end
@@ -217,11 +416,11 @@ module Rigor
217
416
  unless nesting.empty?
218
417
  ancestor_scopes(nesting.join("::")).each do |ancestor|
219
418
  candidate = "#{ancestor}::#{as_written}"
220
- return candidate if @by_fqn.key?(candidate)
419
+ return candidate if declared?(candidate, exclude, local)
221
420
  end
222
421
  end
223
422
 
224
- return as_written if @by_fqn.key?(as_written)
423
+ return as_written if declared?(as_written, exclude, local)
225
424
 
226
425
  # `Scope::DiscoveryIndex::EMPTY` names a constant INSIDE a class, and reading it is a use of that
227
426
  # class — but the leaf is not itself a declaration, so the reference would resolve to nothing and
@@ -229,7 +428,11 @@ module Rigor
229
428
  # on the first run of this report against Rigor's own `lib`). Peel the trailing segment and retry:
230
429
  # a reference to a member is a reference to its owner.
231
430
  idx = as_written.rindex("::")
232
- idx ? resolve(as_written[0, idx], nesting) : nil
431
+ idx ? resolve(as_written[0, idx], nesting, exclude: exclude, local: local) : nil
432
+ end
433
+
434
+ def declared?(fqn, exclude, local)
435
+ fqn != exclude && (@by_fqn.key?(fqn) || local&.include?(fqn))
233
436
  end
234
437
 
235
438
  # Breadth-first over superclass + included modules, mixins first, terminating on a cycle. As-written
@@ -260,7 +463,8 @@ module Rigor
260
463
  segments = subclass_fqn.split("::")
261
464
  (segments.length - 1).downto(0) do |i|
262
465
  candidate = (segments[0, i] + [raw]).join("::")
263
- return candidate if @by_fqn.key?(candidate)
466
+ # `class Api::User < User` cannot inherit from itself; the name was read before it existed.
467
+ return candidate if candidate != subclass_fqn && @by_fqn.key?(candidate)
264
468
  end
265
469
  nil
266
470
  end