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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/data/builtins/ruby_core/array.yml +11 -24
- data/data/builtins/ruby_core/enumerable.yml +4 -8
- data/data/builtins/ruby_core/exception.yml +6 -9
- data/data/builtins/ruby_core/hash.yml +13 -20
- data/data/builtins/ruby_core/io.yml +6 -9
- data/data/builtins/ruby_core/numeric.yml +2 -3
- data/data/builtins/ruby_core/proc.yml +4 -8
- data/data/builtins/ruby_core/re.yml +6 -9
- data/data/builtins/ruby_core/set.yml +2 -3
- data/data/builtins/ruby_core/string.yml +4 -7
- data/data/builtins/ruby_core/struct.yml +0 -6
- data/data/builtins/ruby_core/time.yml +3 -6
- data/data/effects/core.yml +843 -0
- data/data/effects/registry.yml +67 -0
- data/docs/handbook/07-rbs-and-extended.md +106 -5
- data/docs/manual/02-cli-reference.md +311 -1
- data/docs/manual/03-configuration.md +84 -1
- data/docs/manual/04-diagnostics.md +14 -2
- data/docs/manual/16-rbs-extended-annotations.md +77 -0
- data/docs/manual/18-removing-dead-code.md +326 -0
- data/docs/manual/README.md +5 -0
- data/docs/manual/plugins/rigor-actionmailer.md +5 -0
- data/docs/manual/plugins/rigor-activejob.md +47 -0
- data/docs/manual/plugins/rigor-factorybot.md +16 -0
- data/docs/manual/plugins/rigor-pundit.md +26 -0
- data/docs/manual/plugins/rigor-rails-routes.md +41 -2
- data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
- data/docs/manual/plugins/rigor-rspec.md +8 -0
- data/docs/manual/plugins/rigor-sidekiq.md +54 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
- data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
- data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
- data/lib/rigor/analysis/check_rules.rb +62 -22
- data/lib/rigor/analysis/incremental_session.rb +140 -6
- data/lib/rigor/analysis/reachability/graph.rb +244 -0
- data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
- data/lib/rigor/analysis/reachability/project_files.rb +58 -0
- data/lib/rigor/analysis/reachability/scan.rb +241 -0
- data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
- data/lib/rigor/analysis/rule_catalog.rb +163 -1
- data/lib/rigor/analysis/run_cache_key.rb +16 -0
- data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
- data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
- data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
- data/lib/rigor/analysis/runner.rb +420 -10
- data/lib/rigor/analysis/severity_stamp.rb +2 -1
- data/lib/rigor/analysis/worker_session.rb +58 -2
- data/lib/rigor/bleeding_edge.rb +22 -0
- data/lib/rigor/cache/incremental_snapshot.rb +24 -5
- data/lib/rigor/cli/check_command.rb +13 -1
- data/lib/rigor/cli/check_runner_factory.rb +4 -1
- data/lib/rigor/cli/effects_command.rb +131 -0
- data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
- data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
- data/lib/rigor/cli/effects_renderer.rb +54 -0
- data/lib/rigor/cli/effects_report.rb +53 -0
- data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
- data/lib/rigor/cli/unused_command.rb +288 -0
- data/lib/rigor/cli.rb +16 -0
- data/lib/rigor/configuration/severity_profile.rb +23 -0
- data/lib/rigor/configuration.rb +296 -14
- data/lib/rigor/effects/attribution.rb +76 -0
- data/lib/rigor/effects/catalog.rb +275 -0
- data/lib/rigor/effects/collector.rb +182 -0
- data/lib/rigor/effects/config_envelopes.rb +185 -0
- data/lib/rigor/effects/discharge.rb +69 -0
- data/lib/rigor/effects/effect_table.rb +92 -0
- data/lib/rigor/effects/entry_points.rb +93 -0
- data/lib/rigor/effects/envelope.rb +86 -0
- data/lib/rigor/effects/envelope_check.rb +172 -0
- data/lib/rigor/effects/envelope_index.rb +157 -0
- data/lib/rigor/effects/file_collection.rb +160 -0
- data/lib/rigor/effects/framework_units.rb +167 -0
- data/lib/rigor/effects/identity.rb +104 -0
- data/lib/rigor/effects/label.rb +77 -0
- data/lib/rigor/effects/label_intent.rb +73 -0
- data/lib/rigor/effects/label_set.rb +136 -0
- data/lib/rigor/effects/liskov_check.rb +167 -0
- data/lib/rigor/effects/local_ownership.rb +132 -0
- data/lib/rigor/effects/method_key.rb +40 -0
- data/lib/rigor/effects/mutation_classifier.rb +92 -0
- data/lib/rigor/effects/narrowing.rb +202 -0
- data/lib/rigor/effects/origin.rb +66 -0
- data/lib/rigor/effects/path_finder.rb +89 -0
- data/lib/rigor/effects/plugin_facts.rb +335 -0
- data/lib/rigor/effects/propagator.rb +275 -0
- data/lib/rigor/effects/registry.rb +193 -0
- data/lib/rigor/effects/scanner.rb +290 -0
- data/lib/rigor/effects/signature_sources.rb +74 -0
- data/lib/rigor/effects/snapshot.rb +380 -0
- data/lib/rigor/effects/snapshot_diff.rb +265 -0
- data/lib/rigor/effects/summary.rb +154 -0
- data/lib/rigor/effects/taint_cause.rb +38 -0
- data/lib/rigor/effects/unit_scan.rb +572 -0
- data/lib/rigor/effects/unknown_label_check.rb +86 -0
- data/lib/rigor/effects/unknown_label_report.rb +59 -0
- data/lib/rigor/effects.rb +47 -0
- data/lib/rigor/environment/rbs_loader.rb +34 -0
- data/lib/rigor/environment.rb +8 -1
- data/lib/rigor/flow_contribution/element.rb +1 -0
- data/lib/rigor/flow_contribution/merge_result.rb +5 -3
- data/lib/rigor/flow_contribution/merger.rb +16 -1
- data/lib/rigor/flow_contribution.rb +20 -4
- data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
- data/lib/rigor/inference/expression_typer.rb +74 -17
- data/lib/rigor/inference/fork_map.rb +45 -11
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
- data/lib/rigor/inference/method_dispatcher.rb +41 -10
- data/lib/rigor/inference/optimistic_origin.rb +68 -3
- data/lib/rigor/inference/pre_eval_constants.rb +189 -0
- data/lib/rigor/inference/scope_indexer.rb +134 -31
- data/lib/rigor/inference/singleton_object_constant.rb +63 -0
- data/lib/rigor/inference/statement_evaluator.rb +25 -12
- data/lib/rigor/plugin/base.rb +24 -0
- data/lib/rigor/plugin/effect_attribution.rb +208 -0
- data/lib/rigor/plugin/effect_edge.rb +101 -0
- data/lib/rigor/plugin/effect_entry_points.rb +51 -0
- data/lib/rigor/plugin/first_party.rb +57 -0
- data/lib/rigor/plugin/isolation.rb +4 -1
- data/lib/rigor/plugin/manifest.rb +103 -3
- data/lib/rigor/plugin/node_rule_walk.rb +6 -0
- data/lib/rigor/plugin/registry.rb +48 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
- data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
- data/lib/rigor/rbs_extended.rb +168 -0
- data/lib/rigor/reflection.rb +131 -30
- data/lib/rigor/scope.rb +62 -2
- data/lib/rigor/source/node_walker.rb +12 -0
- data/lib/rigor/type/anonymous_class_name.rb +40 -0
- data/lib/rigor/type/nominal.rb +5 -2
- data/lib/rigor/type/singleton.rb +4 -1
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
- data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
- data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
- data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
- data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
- data/sig/rigor/analysis/fact_store.rbs +1 -0
- data/sig/rigor/inference.rbs +2 -0
- data/sig/rigor/rbs_extended.rbs +6 -0
- data/sig/rigor/scope.rbs +4 -0
- data/sig/rigor.rbs +21 -1
- data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
- 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
|