rigortype 0.3.2 → 0.3.4

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 (179) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/array.yml +11 -24
  4. data/data/builtins/ruby_core/enumerable.yml +4 -8
  5. data/data/builtins/ruby_core/exception.yml +6 -9
  6. data/data/builtins/ruby_core/hash.yml +13 -20
  7. data/data/builtins/ruby_core/io.yml +6 -9
  8. data/data/builtins/ruby_core/numeric.yml +2 -3
  9. data/data/builtins/ruby_core/proc.yml +4 -8
  10. data/data/builtins/ruby_core/re.yml +6 -9
  11. data/data/builtins/ruby_core/set.yml +2 -3
  12. data/data/builtins/ruby_core/string.yml +4 -7
  13. data/data/builtins/ruby_core/struct.yml +0 -6
  14. data/data/builtins/ruby_core/time.yml +3 -6
  15. data/data/effects/core.yml +843 -0
  16. data/data/effects/registry.yml +67 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +311 -1
  19. data/docs/manual/03-configuration.md +84 -1
  20. data/docs/manual/04-diagnostics.md +14 -2
  21. data/docs/manual/16-rbs-extended-annotations.md +77 -0
  22. data/docs/manual/18-removing-dead-code.md +326 -0
  23. data/docs/manual/README.md +5 -0
  24. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  25. data/docs/manual/plugins/rigor-activejob.md +47 -0
  26. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  27. data/docs/manual/plugins/rigor-pundit.md +26 -0
  28. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  29. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  30. data/docs/manual/plugins/rigor-rspec.md +8 -0
  31. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  32. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
  33. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  34. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  35. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  36. data/lib/rigor/analysis/check_rules.rb +62 -22
  37. data/lib/rigor/analysis/incremental_session.rb +140 -6
  38. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  39. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  40. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  41. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  42. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  43. data/lib/rigor/analysis/rule_catalog.rb +163 -1
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  46. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  47. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  48. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  50. data/lib/rigor/analysis/runner.rb +420 -10
  51. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  52. data/lib/rigor/analysis/worker_session.rb +58 -2
  53. data/lib/rigor/bleeding_edge.rb +22 -0
  54. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  55. data/lib/rigor/cli/check_command.rb +13 -1
  56. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  57. data/lib/rigor/cli/effects_command.rb +131 -0
  58. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  59. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  60. data/lib/rigor/cli/effects_renderer.rb +54 -0
  61. data/lib/rigor/cli/effects_report.rb +53 -0
  62. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  63. data/lib/rigor/cli/unused_command.rb +288 -0
  64. data/lib/rigor/cli.rb +16 -0
  65. data/lib/rigor/configuration/severity_profile.rb +23 -0
  66. data/lib/rigor/configuration.rb +296 -14
  67. data/lib/rigor/effects/attribution.rb +76 -0
  68. data/lib/rigor/effects/catalog.rb +275 -0
  69. data/lib/rigor/effects/collector.rb +182 -0
  70. data/lib/rigor/effects/config_envelopes.rb +185 -0
  71. data/lib/rigor/effects/discharge.rb +69 -0
  72. data/lib/rigor/effects/effect_table.rb +92 -0
  73. data/lib/rigor/effects/entry_points.rb +93 -0
  74. data/lib/rigor/effects/envelope.rb +86 -0
  75. data/lib/rigor/effects/envelope_check.rb +172 -0
  76. data/lib/rigor/effects/envelope_index.rb +157 -0
  77. data/lib/rigor/effects/file_collection.rb +160 -0
  78. data/lib/rigor/effects/framework_units.rb +167 -0
  79. data/lib/rigor/effects/identity.rb +104 -0
  80. data/lib/rigor/effects/label.rb +77 -0
  81. data/lib/rigor/effects/label_intent.rb +73 -0
  82. data/lib/rigor/effects/label_set.rb +136 -0
  83. data/lib/rigor/effects/liskov_check.rb +167 -0
  84. data/lib/rigor/effects/local_ownership.rb +132 -0
  85. data/lib/rigor/effects/method_key.rb +40 -0
  86. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  87. data/lib/rigor/effects/narrowing.rb +202 -0
  88. data/lib/rigor/effects/origin.rb +66 -0
  89. data/lib/rigor/effects/path_finder.rb +89 -0
  90. data/lib/rigor/effects/plugin_facts.rb +335 -0
  91. data/lib/rigor/effects/propagator.rb +275 -0
  92. data/lib/rigor/effects/registry.rb +193 -0
  93. data/lib/rigor/effects/scanner.rb +290 -0
  94. data/lib/rigor/effects/signature_sources.rb +74 -0
  95. data/lib/rigor/effects/snapshot.rb +380 -0
  96. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  97. data/lib/rigor/effects/summary.rb +154 -0
  98. data/lib/rigor/effects/taint_cause.rb +38 -0
  99. data/lib/rigor/effects/unit_scan.rb +572 -0
  100. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  101. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  102. data/lib/rigor/effects.rb +47 -0
  103. data/lib/rigor/environment/rbs_loader.rb +34 -0
  104. data/lib/rigor/environment.rb +8 -1
  105. data/lib/rigor/flow_contribution/element.rb +1 -0
  106. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  107. data/lib/rigor/flow_contribution/merger.rb +16 -1
  108. data/lib/rigor/flow_contribution.rb +20 -4
  109. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  110. data/lib/rigor/inference/expression_typer.rb +74 -17
  111. data/lib/rigor/inference/fork_map.rb +45 -11
  112. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
  113. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
  114. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  115. data/lib/rigor/inference/optimistic_origin.rb +68 -3
  116. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  117. data/lib/rigor/inference/scope_indexer.rb +134 -31
  118. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  119. data/lib/rigor/inference/statement_evaluator.rb +25 -12
  120. data/lib/rigor/plugin/base.rb +24 -0
  121. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  122. data/lib/rigor/plugin/effect_edge.rb +101 -0
  123. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  124. data/lib/rigor/plugin/first_party.rb +57 -0
  125. data/lib/rigor/plugin/isolation.rb +4 -1
  126. data/lib/rigor/plugin/manifest.rb +103 -3
  127. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  128. data/lib/rigor/plugin/registry.rb +48 -0
  129. data/lib/rigor/plugin.rb +1 -0
  130. data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
  131. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  132. data/lib/rigor/rbs_extended.rb +168 -0
  133. data/lib/rigor/reflection.rb +131 -30
  134. data/lib/rigor/scope.rb +62 -2
  135. data/lib/rigor/source/node_walker.rb +12 -0
  136. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  137. data/lib/rigor/type/nominal.rb +5 -2
  138. data/lib/rigor/type/singleton.rb +4 -1
  139. data/lib/rigor/version.rb +1 -1
  140. data/lib/rigor.rb +1 -0
  141. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  142. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  143. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  144. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  145. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  146. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  147. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  148. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  149. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  150. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  151. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  152. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  153. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  154. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  155. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  156. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  157. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  158. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  159. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  160. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  161. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  162. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  163. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  164. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  165. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  166. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  167. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  168. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  169. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  170. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  171. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  172. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  173. data/sig/rigor/analysis/fact_store.rbs +1 -0
  174. data/sig/rigor/inference.rbs +2 -0
  175. data/sig/rigor/rbs_extended.rbs +6 -0
  176. data/sig/rigor/scope.rbs +4 -0
  177. data/sig/rigor.rbs +21 -1
  178. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  179. metadata +83 -1
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "scan"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ module Reachability
8
+ # ADR-102 — the cross-file half: resolves every as-written reference to a declaration, then marks from the
9
+ # root set. Pure data; no engine coupling, so `rigor check`'s diagnostic stream is untouched by
10
+ # construction rather than by a gate (WD1).
11
+ #
12
+ # Resolution mirrors `Reflection.resolve_constant_type`'s candidate order — lexical nesting innermost
13
+ # first, then the ancestors of the innermost cresting scope, then the bare name — because a reference
14
+ # index that resolved names differently from the engine would report on a graph the analyzer does not
15
+ # believe in. The two walks are separate implementations (this one is name-level and needs no types), so
16
+ # `spec/rigor/analysis/reachability/graph_spec.rb` pins them to the same answers on shared fixtures.
17
+ class Graph
18
+ Candidate = Data.define(:fqn, :path, :line)
19
+
20
+ # `test_only` is its own list, not a flag on `candidates`: a candidate is by definition unreachable, so
21
+ # a flag there could never be true. "Reachable, but only from test code" is a SEPARATE and more
22
+ # actionable answer — dead production code with a live test — which is exactly what ADR-102 WD8 requires
23
+ # be reported as its own category rather than folded into a bucket boundary.
24
+ Report = Data.define(:declared, :reachable, :candidates, :undecidable, :test_only, :namespaces,
25
+ :roots, :edges)
26
+
27
+ # A candidate demoted out of `candidates` because something can reach it by a mechanism this reading
28
+ # cannot follow (ADR-102 WD4). Carries the reason so the reader can judge it rather than take the
29
+ # bucket on trust.
30
+ Undecidable = Data.define(:fqn, :path, :line, :reason)
31
+
32
+ # @param declarations [Array<Scan::Declaration>]
33
+ # @param references [Array<Scan::Reference>]
34
+ # @param root_fqns [Enumerable<String>] declarations that are entry points regardless of who references
35
+ # them (config-declared globs in this slice; plugin-supplied roots are #349).
36
+ # @param foreign [#call] predicate answering "is this FQN owned by something outside the project?" —
37
+ # a reopened gem or stdlib class must never be a candidate (WD6). Defaults to "nothing is foreign".
38
+ # @param dynamic_uses [Array<Scan::DynamicUse>] sites where a constant is reached by name at runtime.
39
+ # A literal-argument site contributes a real reference; a dynamic one taints a namespace (WD4).
40
+ def initialize(declarations:, references:, root_fqns: [], dynamic_uses: [], foreign: ->(_fqn) { false })
41
+ @declarations = declarations
42
+ @dynamic_uses = dynamic_uses
43
+ @references = references + literal_dynamic_references(dynamic_uses)
44
+ @root_fqns = root_fqns.to_set
45
+ @foreign = foreign
46
+ @by_fqn = declarations.group_by(&:fqn)
47
+ @owned = @by_fqn.keys.reject { |fqn| @foreign.call(fqn) }.to_set
48
+ @ancestors = {}
49
+ end
50
+
51
+ def report
52
+ edges = resolved_edges
53
+ production = walk(edges, seeds: production_seeds, roles: %i[production task config])
54
+ reachable = walk(edges, seeds: production_seeds | test_seeds, roles: %i[production task config test])
55
+ unreached = @owned - reachable
56
+ namespaces = namespace_only(unreached, reachable)
57
+ undecidable = tainted(unreached - namespaces)
58
+ Report.new(declared: @owned.size, reachable: reachable.size,
59
+ candidates: rows(unreached - namespaces - undecidable.keys.to_set),
60
+ undecidable: undecidable.map { |fqn, reason| undecidable_row(fqn, reason) }.freeze,
61
+ test_only: rows(reachable - production),
62
+ namespaces: namespaces.size, roots: production_seeds.size, edges: edges.size)
63
+ end
64
+
65
+ private
66
+
67
+ # A literal-argument `"Foo::Bar".constantize` names its constant exactly, so it is a REFERENCE, not an
68
+ # unknown. Keeping this distinct from the taint below is what stops the tier being a blanket namespace
69
+ # poison — Rigor knows the argument's shape, and a type-free indexer does not.
70
+ def literal_dynamic_references(dynamic_uses)
71
+ dynamic_uses.filter_map do |use|
72
+ next if use.name.nil?
73
+
74
+ Scan::Reference.new(as_written: use.name.sub(/\A::/, ""), nesting: [].freeze, from: nil,
75
+ role: :production, path: use.path, line: use.line)
76
+ end
77
+ end
78
+
79
+ # `{fqn => reason}` for every unreached declaration a dynamic site could still be naming. A site with a
80
+ # literal prefix taints that namespace and everything under it; a site with no prefix at all cannot be
81
+ # bounded, so it taints nothing rather than everything — poisoning the whole project would empty the
82
+ # report and teach the reader that the tier means nothing.
83
+ def tainted(unreached)
84
+ prefixes = @dynamic_uses.filter_map { |use| [use.prefix, use] if use.name.nil? && use.prefix }
85
+
86
+ return {} if prefixes.empty?
87
+
88
+ unreached.each_with_object({}) do |fqn, out|
89
+ _, use = prefixes.find { |prefix, _| fqn == prefix || fqn.start_with?("#{prefix}::") }
90
+ out[fqn] = use.site.nil? ? use.reason : "#{use.reason} (#{use.site})" if use
91
+ end
92
+ end
93
+
94
+ def undecidable_row(fqn, reason)
95
+ site = @by_fqn.fetch(fqn).first
96
+ Undecidable.new(fqn: fqn, path: site.path, line: site.line, reason: reason)
97
+ end
98
+
99
+ # `module A; end` wrapping a live `A::B` is not dead code, but nothing ever references `A` by itself:
100
+ # a reference to `A::B::Leaf` records the leaf only, never the intermediate segments. Reporting these
101
+ # buried the real rows — 12 of 18 candidates on Rigor's own `lib`, and 22 of 140 in the #345 probe,
102
+ # were pure namespaces.
103
+ #
104
+ # The test is deliberately "some REACHABLE declaration lives under it", not "some declaration lives
105
+ # under it": a namespace whose entire contents are dead is itself a genuine finding, and its children
106
+ # appear alongside it rather than being explained away.
107
+ def namespace_only(unreached, reachable)
108
+ unreached.select do |fqn|
109
+ prefix = "#{fqn}::"
110
+ reachable.any? { |other| other.start_with?(prefix) }
111
+ end.to_set
112
+ end
113
+
114
+ # Seeds that make a declaration live in PRODUCTION: named entry points, plus anything referenced at
115
+ # file level by a non-test file (file-level code runs on load, so its target is live).
116
+ def production_seeds
117
+ @production_seeds ||= (@root_fqns & @owned) | seeds_from { |ref| ref.from.nil? && ref.role != :test }
118
+ end
119
+
120
+ # Seeds that make a declaration live only through TEST code. Kept separate from production seeds
121
+ # rather than folded in: a spec's file-level `Foo.new` would otherwise promote `Foo` to a root and
122
+ # erase the very distinction WD8 exists to report.
123
+ def test_seeds
124
+ @test_seeds ||= seeds_from { |ref| ref.from.nil? && ref.role == :test }
125
+ end
126
+
127
+ def seeds_from
128
+ set = Set.new
129
+ @references.each do |ref|
130
+ next unless yield(ref)
131
+
132
+ target = resolve(ref.as_written, ref.nesting)
133
+ set << target if target && @owned.include?(target)
134
+ end
135
+ set
136
+ end
137
+
138
+ # `[from_fqn_or_nil, to_fqn, role]` for every reference that resolves to an owned declaration.
139
+ def resolved_edges
140
+ @resolved_edges ||= @references.filter_map do |ref|
141
+ target = resolve(ref.as_written, ref.nesting)
142
+ next unless target && @owned.include?(target)
143
+ next if ref.from == target # a declaration referencing itself is not evidence of use
144
+
145
+ [ref.from, target, ref.role]
146
+ end
147
+ end
148
+
149
+ # Mark-and-sweep, not reference counting: an edge only propagates if its SOURCE is itself reachable, so
150
+ # a cluster of mutually-referencing dead classes stays dead (ADR-102 WD2).
151
+ #
152
+ # Run twice with different edge roles admitted (WD8). The production pass admits everything except
153
+ # test-sourced edges; the full pass admits all of them. The difference is exactly "reachable, but only
154
+ # from test code" — dead production code with a live test, which is a finding rather than a bucket edge.
155
+ def walk(edges, seeds:, roles:)
156
+ admitted = roles.to_set
157
+ out = Hash.new { |h, k| h[k] = [] }
158
+ edges.each { |from, to, role| out[from] << to if admitted.include?(role) }
159
+
160
+ seen = seeds.dup
161
+ queue = seeds.to_a
162
+ until queue.empty?
163
+ out[queue.shift].each do |target|
164
+ next if seen.include?(target)
165
+
166
+ seen << target
167
+ queue << target
168
+ end
169
+ end
170
+ seen
171
+ end
172
+
173
+ def rows(fqns)
174
+ fqns.sort.map do |fqn|
175
+ site = @by_fqn.fetch(fqn).first
176
+ Candidate.new(fqn: fqn, path: site.path, line: site.line)
177
+ end.freeze
178
+ end
179
+
180
+ # Ruby's constant lookup at name granularity: `Module.nesting` innermost first, then the ancestors of
181
+ # the innermost cresting scope (#354), then the bare name.
182
+ def resolve(as_written, nesting)
183
+ walker = nesting.dup
184
+ until walker.empty?
185
+ candidate = (walker + [as_written]).join("::")
186
+ return candidate if @by_fqn.key?(candidate)
187
+
188
+ walker.pop
189
+ end
190
+
191
+ unless nesting.empty?
192
+ ancestor_scopes(nesting.join("::")).each do |ancestor|
193
+ candidate = "#{ancestor}::#{as_written}"
194
+ return candidate if @by_fqn.key?(candidate)
195
+ end
196
+ end
197
+
198
+ return as_written if @by_fqn.key?(as_written)
199
+
200
+ # `Scope::DiscoveryIndex::EMPTY` names a constant INSIDE a class, and reading it is a use of that
201
+ # class — but the leaf is not itself a declaration, so the reference would resolve to nothing and
202
+ # `Scope::DiscoveryIndex` would be reported as unused despite being read all over the engine (it was,
203
+ # on the first run of this report against Rigor's own `lib`). Peel the trailing segment and retry:
204
+ # a reference to a member is a reference to its owner.
205
+ idx = as_written.rindex("::")
206
+ idx ? resolve(as_written[0, idx], nesting) : nil
207
+ end
208
+
209
+ # Breadth-first over superclass + included modules, mixins first, terminating on a cycle. As-written
210
+ # ancestor names resolve against the subclass's own nesting; a name naming no declaration is dropped.
211
+ def ancestor_scopes(fqn)
212
+ @ancestors[fqn] ||= begin
213
+ seen = Set[fqn]
214
+ queue = [fqn]
215
+ out = []
216
+ until queue.empty?
217
+ current = queue.shift
218
+ @by_fqn.fetch(current, []).each do |decl|
219
+ (decl.includes + [decl.superclass]).compact.each do |raw|
220
+ resolved = resolve_ancestor(current, raw)
221
+ next if resolved.nil? || seen.include?(resolved)
222
+
223
+ seen << resolved
224
+ out << resolved
225
+ queue << resolved
226
+ end
227
+ end
228
+ end
229
+ out.freeze
230
+ end
231
+ end
232
+
233
+ def resolve_ancestor(subclass_fqn, raw)
234
+ segments = subclass_fqn.split("::")
235
+ (segments.length - 1).downto(0) do |i|
236
+ candidate = (segments[0, i] + [raw]).join("::")
237
+ return candidate if @by_fqn.key?(candidate)
238
+ end
239
+ nil
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../plugin"
4
+ require_relative "../../plugin/loader"
5
+ require_relative "../../plugin/services"
6
+ require_relative "../../reflection"
7
+ require_relative "../../type/combinator"
8
+
9
+ module Rigor
10
+ module Analysis
11
+ module Reachability
12
+ # ADR-102 WD3 — the plugin contribution protocol: framework knowledge stays in plugins, and the core
13
+ # defines only the channel it arrives through.
14
+ #
15
+ # The channel is the EXISTING cross-plugin fact mechanism (ADR-9), not a new hook. A plugin declares
16
+ # `produces: [:reachability_roots]` in its manifest and publishes an Array of fully-qualified constant
17
+ # names from its `#prepare(services)` hook; this module loads the project's plugins, runs every
18
+ # `#prepare`, and hands the union to {Graph}'s `root_fqns:`. Reusing the fact store means the ordering
19
+ # guarantee is already settled — `#prepare` runs before anything reads the store — and a plugin that
20
+ # already computes the knowledge (`rigor-rails-routes` parses `config/routes.rb` for its helper table
21
+ # regardless) publishes a second view of it for free.
22
+ #
23
+ # Why roots at all: a Rails controller is reached by NAME at request time, so a reference index sees a
24
+ # live controller exactly as it sees a dead one. Route roots were the single largest lever in the #345
25
+ # corpus measurement — subtracting them removed 40 % / 62 % / 67 % of the class tier across three
26
+ # targets, more than every other stage combined.
27
+ #
28
+ # **Two facts, because a root is not the only thing a plugin knows.** `:reachability_references` is the
29
+ # sibling fact (#350): an Array of `{name:, role:}`, threaded into the graph as extra REFERENCES rather
30
+ # than as roots. FactoryBot is the motivating case — `factory :user, class: "Admin::User"` names a class
31
+ # by a string the constant scan cannot see, so it is real evidence of use, but a factory lives in the
32
+ # test tree and rooting it would promote `Admin::User` to production-reachable and erase WD8's
33
+ # "reachable only from tests" answer for it. Carrying the referrer's role keeps the two apart.
34
+ #
35
+ # The module keeps its `PluginRoots` name because roots stay its primary product, and because both
36
+ # facts arrive through ONE plugin-load pass — loading the registry a second time would double a
37
+ # report's startup cost to collect a strictly smaller fact.
38
+ #
39
+ # **Fail-soft is the invariant.** A project with no plugins, a plugin gem that will not resolve, or a
40
+ # plugin that raises in `#prepare` degrades to the root set the report had without plugins — never a
41
+ # crash. `rigor unused` is a report a human reads; refusing to print it because one plugin is
42
+ # misconfigured trades a slightly wider candidate list for no output at all.
43
+ #
44
+ # **Under-supply beats over-supply.** A root that names a constant the project does not declare is
45
+ # inert (the graph intersects roots with owned declarations), but a root claiming a namespace it does
46
+ # not really reach silently hides real dead code. That asymmetry is why {.collect} validates the shape
47
+ # of what a plugin publishes rather than trusting it, and why the report surfaces the count of
48
+ # supplied roots that matched no declaration — a plugin's contribution needs its own corpus check
49
+ # (ADR-102 § Consequences).
50
+ module PluginRoots
51
+ # The fact name a root-contributing plugin publishes under. Namespaced by `plugin_id` inside the
52
+ # store, so two plugins contributing roots never collide.
53
+ FACT_NAME = :reachability_roots
54
+
55
+ # The sibling fact: references that carry their referrer's role instead of rooting anything (#350).
56
+ REFERENCE_FACT_NAME = :reachability_references
57
+
58
+ # A published entry is accepted only when it is shaped like a constant path. The filter is not a
59
+ # security boundary — plugins are trusted code (ADR-2) — it keeps a plugin bug (a path, a helper
60
+ # name, a nil) from entering the graph as a root that silently matches nothing.
61
+ CONSTANT_NAME = /\A(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*\z/
62
+
63
+ # The roles {Scan.role_for} assigns a file, and therefore the only roles a plugin may claim for a
64
+ # reference it contributes. An unrecognised role is dropped rather than defaulted: defaulting to
65
+ # `:production` would silently promote a test-tree reference, which is the one outcome WD8 exists to
66
+ # prevent.
67
+ ROLES = %i[production test task config].freeze
68
+
69
+ # One plugin-contributed reference. `role` is the role of whatever names the constant, exactly as a
70
+ # scanned file's role would be.
71
+ Reference = Data.define(:name, :role)
72
+
73
+ # Everything one plugin-load pass yields. Roots seed the mark-and-sweep; references enter the graph
74
+ # beside the scanned ones.
75
+ Contribution = Data.define(:roots, :references) do
76
+ def self.empty = new(roots: [].freeze, references: [].freeze)
77
+
78
+ def empty? = roots.empty? && references.empty?
79
+ end
80
+
81
+ module_function
82
+
83
+ # Loads the project's configured plugins, runs every `#prepare`, and returns the union of every
84
+ # published `:reachability_roots` and `:reachability_references` fact.
85
+ #
86
+ # @param configuration [Rigor::Configuration] the loaded project configuration.
87
+ # @param plugin_requirer [#call] how a plugin gem is brought into the process. The same seam
88
+ # `Analysis::Runner` exposes, so a spec can register a plugin class without publishing a gem.
89
+ # @return [Contribution] sorted and de-duplicated. Empty whenever the project declares no plugins, no
90
+ # plugin contributes anything, or anything at all goes wrong.
91
+ def collect(configuration:, plugin_requirer: ->(name) { require name })
92
+ return Contribution.empty if configuration.plugins.empty?
93
+
94
+ services = build_services(configuration)
95
+ registry = Plugin::Loader.load(configuration: configuration, services: services,
96
+ requirer: plugin_requirer)
97
+ return Contribution.empty if registry.nil? || registry.empty?
98
+
99
+ run_prepare(registry)
100
+ harvest(services.fact_store)
101
+ rescue StandardError, ScriptError
102
+ Contribution.empty
103
+ end
104
+
105
+ # Mirrors `CLI::ProbeEnvironment.load_plugin_registry`: a `Plugin::Services` with no cache store
106
+ # (this is a one-shot report, so a producer recomputes rather than reading and writing cache slots)
107
+ # driving `Plugin::Loader.load`. Holding the `Services` is what gives access to the fact store the
108
+ # loaded plugins share — the loader hands the same instance to every plugin.
109
+ def build_services(configuration)
110
+ Plugin::Services.new(
111
+ reflection: Reflection,
112
+ type: Type::Combinator,
113
+ configuration: configuration,
114
+ cache_store: nil
115
+ )
116
+ end
117
+
118
+ # Per-plugin isolation, matching `Analysis::WorkerSession#run_plugin_prepare`: one raising plugin
119
+ # loses its own facts, never the facts of the plugins beside it.
120
+ def run_prepare(registry)
121
+ registry.plugins.each do |plugin|
122
+ plugin.prepare(plugin.services)
123
+ rescue StandardError, ScriptError
124
+ next
125
+ end
126
+ end
127
+
128
+ # Reads every plugin's contribution. `each_fact` rather than a `read(plugin_id:, name:)` per known
129
+ # producer: the core deliberately does not know WHICH plugins contribute roots, which is the whole
130
+ # point of routing this through the fact store instead of an allow-list in the core.
131
+ def harvest(fact_store)
132
+ roots = Set.new
133
+ references = Set.new
134
+ fact_store.each_fact do |fact|
135
+ case fact.name
136
+ when FACT_NAME then Array(fact.value).each { |entry| collect_root(entry, roots) }
137
+ when REFERENCE_FACT_NAME then Array(fact.value).each { |entry| collect_reference(entry, references) }
138
+ end
139
+ end
140
+ Contribution.new(roots: roots.to_a.sort.freeze,
141
+ references: references.to_a.sort_by { |ref| [ref.name, ref.role] }.freeze)
142
+ end
143
+
144
+ def collect_root(entry, roots)
145
+ name = entry.to_s
146
+ roots << name.delete_prefix("::") if CONSTANT_NAME.match?(name)
147
+ end
148
+
149
+ # A reference entry is a Hash so the fact stays self-describing across the store — a bare pair would
150
+ # read identically whichever way round a plugin author wrote it. String and Symbol keys are both
151
+ # accepted because a plugin may have round-tripped the value through a cache slot.
152
+ def collect_reference(entry, references)
153
+ return unless entry.is_a?(Hash)
154
+
155
+ name = (entry[:name] || entry["name"]).to_s
156
+ role = (entry[:role] || entry["role"])&.to_sym
157
+ return unless CONSTANT_NAME.match?(name) && ROLES.include?(role)
158
+
159
+ references << Reference.new(name: name.delete_prefix("::"), role: role)
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ module Reachability
6
+ # Which files in a checkout are the PROJECT's, for the purpose of harvesting references.
7
+ #
8
+ # `rigor unused` reads references from the whole project rather than only the analysed `paths:` (ADR-102
9
+ # WD7), which makes "the whole project" a question that needs answering rather than assuming.
10
+ module ProjectFiles
11
+ # Trees that are never a project's own source. A reference found inside a vendored gem is not evidence
12
+ # about this project, and globbing them can cost more than every analysed file combined.
13
+ VENDOR_DIRS = %r{\A(vendor|node_modules|tmp|\.git|\.rigor|coverage)/}
14
+
15
+ module_function
16
+
17
+ # Path prefixes of the checkout's git submodules, from `.gitmodules`.
18
+ #
19
+ # A submodule is a separate project that happens to live inside this one; its contents are neither this
20
+ # project's declarations nor references to them. Rigor's own repository is the case that made this
21
+ # unmissable — it vendors upstream sources under `references/`, 13,894 of the 18,002 files the
22
+ # reference glob would otherwise read, and one of them crashed the run outright.
23
+ #
24
+ # Reads `.gitmodules` rather than probing for nested `.git` entries: it is one cheap file against a
25
+ # second full tree walk, and it is what the repository itself declares. A nested checkout that is not a
26
+ # registered submodule is therefore not detected — `exclude:` covers that case.
27
+ def submodule_prefixes(root)
28
+ gitmodules = File.join(root, ".gitmodules")
29
+ return [] unless File.file?(gitmodules)
30
+
31
+ File.read(gitmodules).scan(/^\s*path\s*=\s*(.+)$/).flatten.filter_map do |path|
32
+ trimmed = path.strip
33
+ "#{trimmed.delete_suffix('/')}/" unless trimmed.empty?
34
+ end
35
+ rescue SystemCallError
36
+ []
37
+ end
38
+
39
+ # Does an `--entry-point` glob match this path?
40
+ #
41
+ # `File::FNM_PATHNAME` is what makes `**` mean "zero or more directories" rather than "one or more".
42
+ # Without it `lib/workers/**/*.rb` does not match `lib/workers/a.rb` — only files nested a level
43
+ # deeper — and a user writing that glob means the whole tree. The failure is silent and in the bad
44
+ # direction: the top-level declarations stay in the report and read as dead code.
45
+ def entry_point_match?(pattern, path)
46
+ File.fnmatch?(pattern, path, File::FNM_PATHNAME)
47
+ end
48
+
49
+ # @param relative_paths [Array<String>] paths relative to `root`.
50
+ # @return [Array<String>] those that belong to the project itself.
51
+ def own(relative_paths, root)
52
+ prefixes = submodule_prefixes(root)
53
+ relative_paths.grep_v(VENDOR_DIRS).reject { |rel| prefixes.any? { |prefix| rel.start_with?(prefix) } }
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end