rigortype 0.3.3 → 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 +6 -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/rule_ids.rb +28 -2
- 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 +158 -0
- 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/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/expression_typer.rb +18 -4
- data/lib/rigor/inference/pre_eval_constants.rb +189 -0
- data/lib/rigor/inference/scope_indexer.rb +15 -3
- 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/registry.rb +48 -0
- data/lib/rigor/plugin.rb +1 -0
- 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/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.rbs +17 -1
- data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
- metadata +79 -1
|
@@ -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
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../../source/constant_path"
|
|
6
|
+
require_relative "../../source/node_children"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Analysis
|
|
10
|
+
module Reachability
|
|
11
|
+
# ADR-102 — the per-file half of the reference index: one Prism walk that records every constant
|
|
12
|
+
# DECLARATION and every constant REFERENCE, each with the lexical nesting in force at that point.
|
|
13
|
+
#
|
|
14
|
+
# This is deliberately NOT a hook on the typing path. `Reflection.resolve_constant_type` fires only where
|
|
15
|
+
# the engine needs a constant's *type*, so a constant that is read but never typed leaves no trace there —
|
|
16
|
+
# the #345 measurement found five distinct losses (cross-file value constants, parameter defaults,
|
|
17
|
+
# lambda-rvalue bodies, superclass positions, intermediate namespace segments) and produced 140 candidates
|
|
18
|
+
# of which zero were genuine. A reference index has to see every constant node regardless of whether a type
|
|
19
|
+
# was wanted, which is what this walk does.
|
|
20
|
+
#
|
|
21
|
+
# Names are recorded AS WRITTEN together with their nesting; resolution to a fully-qualified name happens
|
|
22
|
+
# in {Graph}, after every file has been scanned, because a bare `Foo` cannot be resolved until the whole
|
|
23
|
+
# declaration set is known.
|
|
24
|
+
module Scan
|
|
25
|
+
# A class / module declaration, or a constant assigned one of the meta-new forms. `nesting` is the
|
|
26
|
+
# enclosing declaration path at the declaration site, so `fqn` is exact.
|
|
27
|
+
Declaration = Data.define(:fqn, :path, :line, :superclass, :includes)
|
|
28
|
+
|
|
29
|
+
# One constant reference. `from` is the fully-qualified name of the innermost enclosing declaration, or
|
|
30
|
+
# nil for a reference written at file level — that is what makes the graph a reachability graph rather
|
|
31
|
+
# than a reference count (ADR-102 WD2). `role` is the referring FILE's role (WD8).
|
|
32
|
+
Reference = Data.define(:as_written, :nesting, :from, :role, :path, :line)
|
|
33
|
+
|
|
34
|
+
# ADR-102 WD4 — a site where a constant is reached by a mechanism the static reading cannot follow.
|
|
35
|
+
# `name` is the exact constant when the argument is a literal (`"Foo".constantize`), in which case this
|
|
36
|
+
# is as good as a reference. `prefix` is the namespace a dynamic construction can reach into
|
|
37
|
+
# (`"Foo::#{k}".constantize` → `"Foo"`, and `nil` when even that is unknown), which taints every
|
|
38
|
+
# declaration at or below it rather than proving any single one used.
|
|
39
|
+
DynamicUse = Data.define(:name, :prefix, :reason, :site, :path, :line)
|
|
40
|
+
|
|
41
|
+
Result = Data.define(:declarations, :references, :dynamic_uses)
|
|
42
|
+
|
|
43
|
+
# Roles a referring file can have (ADR-102 WD8). A reference edge carries its referrer's role so
|
|
44
|
+
# "used only by its own test" is a reportable category rather than a bucket boundary.
|
|
45
|
+
def self.role_for(path)
|
|
46
|
+
case path
|
|
47
|
+
when %r{(\A|/)(spec|test)/}, /_(spec|test)\.rb\z/ then :test
|
|
48
|
+
when /\.rake\z/, %r{(\A|/)(lib/)?tasks/} then :task
|
|
49
|
+
when %r{(\A|/)config/} then :config
|
|
50
|
+
else :production
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param path [String] the file's path, as the report should render it.
|
|
55
|
+
# @param source [String] the file's bytes.
|
|
56
|
+
# @param target_ruby [String, nil] Prism version string, threaded from the project configuration.
|
|
57
|
+
# @return [Result, nil] nil when the file does not parse (a parse error is the analyzer's business, not
|
|
58
|
+
# this scan's — it simply contributes nothing rather than half a file).
|
|
59
|
+
# A constant name is ASCII by construction, so a byte sequence that is not valid UTF-8 cannot be one.
|
|
60
|
+
# Dropping it is both correct and the only safe answer: carrying it forward crashed the whole run on
|
|
61
|
+
# the first `String#sub` downstream, which is how this surfaced — `rigor unused` on Rigor's own
|
|
62
|
+
# repository, which vendors a CRuby checkout containing deliberately ill-encoded encoding fixtures.
|
|
63
|
+
def self.usable_name(raw)
|
|
64
|
+
return nil if raw.nil?
|
|
65
|
+
|
|
66
|
+
name = raw.dup.force_encoding(Encoding::UTF_8)
|
|
67
|
+
name.valid_encoding? ? name : nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.call(path:, source:, target_ruby: nil)
|
|
71
|
+
parsed = if target_ruby
|
|
72
|
+
Prism.parse(source, filepath: path,
|
|
73
|
+
version: target_ruby)
|
|
74
|
+
else
|
|
75
|
+
Prism.parse(source, filepath: path)
|
|
76
|
+
end
|
|
77
|
+
return nil unless parsed.success?
|
|
78
|
+
|
|
79
|
+
walker = Walker.new(path: path, role: role_for(path))
|
|
80
|
+
walker.walk(parsed.value, [])
|
|
81
|
+
Result.new(declarations: walker.declarations.freeze, references: walker.references.freeze,
|
|
82
|
+
dynamic_uses: walker.dynamic_uses.freeze)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Single-pass walker. Tracks `nesting` as the stack of enclosing declaration names.
|
|
86
|
+
class Walker
|
|
87
|
+
attr_reader :declarations, :references, :dynamic_uses
|
|
88
|
+
|
|
89
|
+
def initialize(path:, role:)
|
|
90
|
+
@path = path
|
|
91
|
+
@role = role
|
|
92
|
+
@declarations = []
|
|
93
|
+
@references = []
|
|
94
|
+
@dynamic_uses = []
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def walk(node, nesting)
|
|
98
|
+
return unless node.is_a?(Prism::Node)
|
|
99
|
+
|
|
100
|
+
case node
|
|
101
|
+
when Prism::ClassNode then return walk_declaration(node, nesting, superclass: node.superclass)
|
|
102
|
+
when Prism::ModuleNode then return walk_declaration(node, nesting, superclass: nil)
|
|
103
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
104
|
+
record_reference(node, nesting)
|
|
105
|
+
# A constant path's segments are not separate references — `A::B::C` is one reference to the leaf,
|
|
106
|
+
# and descending would record `A` and `A::B` as references in their own right (22 spurious
|
|
107
|
+
# candidates on Rigor's own lib came from exactly that in the #345 probe).
|
|
108
|
+
return
|
|
109
|
+
when Prism::ConstantWriteNode
|
|
110
|
+
record_meta_new(node, nesting)
|
|
111
|
+
walk(node.value, nesting)
|
|
112
|
+
return
|
|
113
|
+
when Prism::CallNode
|
|
114
|
+
record_dynamic_use(node)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
node.rigor_each_child { |child| walk(child, nesting) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def walk_declaration(node, nesting, superclass:)
|
|
123
|
+
name = Source::ConstantPath.qualified_name(node.constant_path)
|
|
124
|
+
return node.rigor_each_child { |child| walk(child, nesting) } if name.nil?
|
|
125
|
+
|
|
126
|
+
fqn = (nesting + [name]).join("::")
|
|
127
|
+
# The superclass position IS a reference — `class Sub < Base` reads `Base` — and it resolves against
|
|
128
|
+
# the OUTER nesting, not inside the body being opened.
|
|
129
|
+
record_reference(superclass, nesting) if superclass
|
|
130
|
+
includes = node.body ? mixin_names(node.body) : []
|
|
131
|
+
@declarations << Declaration.new(fqn: fqn, path: @path, line: node.location.start_line,
|
|
132
|
+
superclass: superclass && Source::ConstantPath.qualified_name(superclass),
|
|
133
|
+
includes: includes.freeze)
|
|
134
|
+
walk(node.body, nesting + [name]) if node.body
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# `Const = Class.new` / `Module.new` / `Data.define(...)` / `Struct.new(...)` declare a class under a
|
|
138
|
+
# constant, and `ScopeIndexer#record_class_new_constant_decl` already treats them as class declarations
|
|
139
|
+
# for cross-file resolution. The report must agree, or every such constant reads as an unreferenced
|
|
140
|
+
# value rather than a class.
|
|
141
|
+
META_NEW = { "Class" => :new, "Module" => :new, "Data" => :define, "Struct" => :new }.freeze
|
|
142
|
+
private_constant :META_NEW
|
|
143
|
+
|
|
144
|
+
def record_meta_new(node, nesting)
|
|
145
|
+
call = node.value
|
|
146
|
+
return unless call.is_a?(Prism::CallNode)
|
|
147
|
+
|
|
148
|
+
recv = call.receiver
|
|
149
|
+
return unless recv.is_a?(Prism::ConstantReadNode) && META_NEW[recv.name.to_s] == call.name
|
|
150
|
+
|
|
151
|
+
@declarations << Declaration.new(fqn: (nesting + [node.name.to_s]).join("::"), path: @path,
|
|
152
|
+
line: node.location.start_line, superclass: nil, includes: [].freeze)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# `include` / `prepend` / `extend` argument names written directly in the declaration body. Only the
|
|
156
|
+
# top level of the body is inspected: a mixin applied inside a conditional or a nested def is not a
|
|
157
|
+
# static ancestor edge.
|
|
158
|
+
def mixin_names(body)
|
|
159
|
+
body.child_nodes.filter_map do |stmt|
|
|
160
|
+
next unless stmt.is_a?(Prism::CallNode) && %i[include prepend extend].include?(stmt.name)
|
|
161
|
+
next if stmt.receiver
|
|
162
|
+
|
|
163
|
+
arg = stmt.arguments&.arguments&.first
|
|
164
|
+
arg && Source::ConstantPath.qualified_name_or_nil(arg)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Names that turn a String into a constant. `constantize` / `safe_constantize` are ActiveSupport;
|
|
169
|
+
# `const_get` is core and may carry an explicit receiver (`Object.const_get`, `self.class.const_get`).
|
|
170
|
+
DYNAMIC_RESOLVERS = %i[constantize safe_constantize const_get].freeze
|
|
171
|
+
private_constant :DYNAMIC_RESOLVERS
|
|
172
|
+
|
|
173
|
+
SUBJECT_SHAPES = {
|
|
174
|
+
Prism::StringNode => "a literal string",
|
|
175
|
+
Prism::SymbolNode => "a literal symbol",
|
|
176
|
+
Prism::InterpolatedStringNode => "an interpolated string"
|
|
177
|
+
}.freeze
|
|
178
|
+
private_constant :SUBJECT_SHAPES
|
|
179
|
+
|
|
180
|
+
# Rigor knows the argument's shape, which is the whole reason this can be tiered rather than treated
|
|
181
|
+
# as a blanket namespace poison: a literal argument names the exact constant and is as good as a
|
|
182
|
+
# written reference, while an interpolated one can only bound the namespace it reaches into.
|
|
183
|
+
def record_dynamic_use(node)
|
|
184
|
+
return unless DYNAMIC_RESOLVERS.include?(node.name)
|
|
185
|
+
|
|
186
|
+
subject = node.name == :const_get ? node.arguments&.arguments&.first : node.receiver
|
|
187
|
+
return if subject.nil?
|
|
188
|
+
|
|
189
|
+
name, prefix = dynamic_target(subject)
|
|
190
|
+
# A literal whose bytes are not valid UTF-8 cannot name a constant; dropping it is the only safe
|
|
191
|
+
# answer, and carrying it forward crashed the whole run downstream.
|
|
192
|
+
return if subject.is_a?(Prism::StringNode) && name.nil?
|
|
193
|
+
return if subject.is_a?(Prism::SymbolNode) && name.nil?
|
|
194
|
+
|
|
195
|
+
@dynamic_uses << DynamicUse.new(
|
|
196
|
+
name: name, prefix: prefix, site: site(node), path: @path, line: node.location.start_line,
|
|
197
|
+
reason: "#{node.name} on #{SUBJECT_SHAPES.fetch(subject.class, 'a computed value')}"
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# `[exact name, bounded namespace]` for a dynamic-resolution subject. A literal names its constant
|
|
202
|
+
# exactly; an interpolation can only bound the namespace its literal head names; anything else bounds
|
|
203
|
+
# nothing.
|
|
204
|
+
def dynamic_target(subject)
|
|
205
|
+
case subject
|
|
206
|
+
when Prism::StringNode, Prism::SymbolNode then [Scan.usable_name(subject.unescaped), nil]
|
|
207
|
+
when Prism::InterpolatedStringNode then [nil, literal_prefix(subject)]
|
|
208
|
+
else [nil, nil]
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The literal head of an interpolated name: `"Foo::Bar::#{k}"` bounds the reach to `Foo::Bar`. Returns
|
|
213
|
+
# nil when the interpolation starts the string, which bounds nothing.
|
|
214
|
+
def site(node)
|
|
215
|
+
"#{@path}:#{node.location.start_line}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def literal_prefix(node)
|
|
219
|
+
head = node.parts.first
|
|
220
|
+
return nil unless head.is_a?(Prism::StringNode)
|
|
221
|
+
|
|
222
|
+
literal = Scan.usable_name(head.unescaped)
|
|
223
|
+
return nil if literal.nil?
|
|
224
|
+
|
|
225
|
+
trimmed = literal.sub(/::\z/, "")
|
|
226
|
+
trimmed.empty? ? nil : trimmed
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def record_reference(node, nesting)
|
|
230
|
+
as_written = Source::ConstantPath.qualified_name_or_nil(node)
|
|
231
|
+
return if as_written.nil?
|
|
232
|
+
|
|
233
|
+
@references << Reference.new(as_written: as_written, nesting: nesting.dup.freeze,
|
|
234
|
+
from: nesting.empty? ? nil : nesting.join("::"),
|
|
235
|
+
role: @role, path: @path, line: node.location.start_line)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbs"
|
|
4
|
+
|
|
5
|
+
require_relative "scan"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Analysis
|
|
9
|
+
module Reachability
|
|
10
|
+
# ADR-102 WD7 / issue #363 — the RBS half of the reference corpus.
|
|
11
|
+
#
|
|
12
|
+
# A constant named only from the project's own `sig/` is genuinely referenced, which is why signatures
|
|
13
|
+
# are read at all: the #345 probe reported such constants as false candidates until an RBS-side hook was
|
|
14
|
+
# added. But a signature file also DECLARES, and the two must not be confused.
|
|
15
|
+
#
|
|
16
|
+
# The first implementation scanned each file for constant-shaped tokens, which cannot tell the
|
|
17
|
+
# difference. With `rbs-inline`-generated signatures — where every project class has a mirror
|
|
18
|
+
# declaration under `sig/` — that made a large fraction of the project unconditionally reachable: on one
|
|
19
|
+
# application 48 of 101 roots came from `sig/`, hiding seven candidates and ten test-only rows. Over-supply
|
|
20
|
+
# is the worse direction (an under-supplied root leaves a row where a human can see it; an over-supplied
|
|
21
|
+
# one removes it where nobody will), so this parses instead.
|
|
22
|
+
#
|
|
23
|
+
# The rule: a declaration's own name is a DECLARATION and contributes nothing. Every type name appearing
|
|
24
|
+
# in a POSITION — a superclass, a mixin argument, a parameter or return type, a constant's type, a type
|
|
25
|
+
# alias body, a generic argument — is a reference and counts.
|
|
26
|
+
module SignatureScan
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# @param file [String] path to a `.rbs` file.
|
|
30
|
+
# @return [Array<Scan::Reference>] one file-level reference per distinct referenced name. Empty when the
|
|
31
|
+
# file cannot be read or parsed — a broken signature is the analyzer's business, not this scan's.
|
|
32
|
+
def call(file)
|
|
33
|
+
_, _, decls = ::RBS::Parser.parse_signature(::RBS::Buffer.new(name: file, content: File.read(file)))
|
|
34
|
+
found = []
|
|
35
|
+
decls.each { |decl| walk(decl, [], found) }
|
|
36
|
+
found.uniq.map do |name, nesting|
|
|
37
|
+
Scan::Reference.new(as_written: name, nesting: nesting, from: nil, role: :config, path: file,
|
|
38
|
+
line: 1)
|
|
39
|
+
end
|
|
40
|
+
rescue ::RBS::BaseError, SystemCallError, ArgumentError
|
|
41
|
+
[]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Walks a declaration, threading the lexical nesting so a name written relative to an enclosing module
|
|
45
|
+
# resolves the way the graph's own candidate walk expects. Without this, `module A; class B < C; end`
|
|
46
|
+
# would contribute a bare `C` that resolves against nothing.
|
|
47
|
+
def walk(node, nesting, out)
|
|
48
|
+
case node
|
|
49
|
+
when ::RBS::AST::Declarations::Class then walk_class(node, nesting, out)
|
|
50
|
+
when ::RBS::AST::Declarations::Module then walk_module(node, nesting, out)
|
|
51
|
+
when ::RBS::AST::Declarations::Interface
|
|
52
|
+
descend(node, nesting + segments(node.name), out)
|
|
53
|
+
when ::RBS::AST::Members::Include, ::RBS::AST::Members::Extend, ::RBS::AST::Members::Prepend
|
|
54
|
+
record(node.name, nesting, out)
|
|
55
|
+
node.args&.each { |arg| collect_types(arg, nesting, out) }
|
|
56
|
+
when ::RBS::AST::Members::MethodDefinition
|
|
57
|
+
node.overloads.each { |overload| collect_types(overload.method_type, nesting, out) }
|
|
58
|
+
when *TYPED_NODES
|
|
59
|
+
collect_types(node.type, nesting, out)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Nodes whose whole contribution is the type they carry: a constant's or alias's right-hand side, and
|
|
64
|
+
# the attribute / variable families.
|
|
65
|
+
TYPED_NODES = [
|
|
66
|
+
::RBS::AST::Declarations::Constant, ::RBS::AST::Declarations::TypeAlias,
|
|
67
|
+
::RBS::AST::Members::AttrReader, ::RBS::AST::Members::AttrWriter,
|
|
68
|
+
::RBS::AST::Members::AttrAccessor, ::RBS::AST::Members::InstanceVariable,
|
|
69
|
+
::RBS::AST::Members::ClassInstanceVariable, ::RBS::AST::Members::ClassVariable
|
|
70
|
+
].freeze
|
|
71
|
+
|
|
72
|
+
def walk_class(node, nesting, out)
|
|
73
|
+
record(node.super_class&.name, nesting, out)
|
|
74
|
+
node.super_class&.args&.each { |arg| collect_types(arg, nesting, out) }
|
|
75
|
+
descend(node, nesting + segments(node.name), out)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def walk_module(node, nesting, out)
|
|
79
|
+
node.self_types&.each { |self_type| record(self_type.name, nesting, out) }
|
|
80
|
+
descend(node, nesting + segments(node.name), out)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def descend(node, nesting, out)
|
|
84
|
+
node.members&.each { |member| walk(member, nesting, out) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Every `RBS::TypeName` reachable from a type, found reflectively. RBS's type zoo is wide (unions,
|
|
88
|
+
# intersections, tuples, records, optionals, procs, generics) and grows between releases; enumerating
|
|
89
|
+
# the classes would silently drop references the day a new one lands, and dropping a reference here
|
|
90
|
+
# manufactures a false candidate.
|
|
91
|
+
def collect_types(type, nesting, out)
|
|
92
|
+
return if type.nil?
|
|
93
|
+
|
|
94
|
+
record(type.name, nesting, out) if type.respond_to?(:name) && type.name.is_a?(::RBS::TypeName)
|
|
95
|
+
type.instance_variables.each do |ivar|
|
|
96
|
+
value = type.instance_variable_get(ivar)
|
|
97
|
+
case value
|
|
98
|
+
when ::RBS::TypeName then record(value, nesting, out)
|
|
99
|
+
when Array then value.each { |v| collect_types(v, nesting, out) }
|
|
100
|
+
when Hash then value.each_value { |v| collect_types(v, nesting, out) }
|
|
101
|
+
else collect_types(value, nesting, out) if value.respond_to?(:instance_variables)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Records the name AS WRITTEN together with the nesting it was written under, and lets
|
|
107
|
+
# {Graph#resolve} do the lexical walk — the same walk it does for a name read out of Ruby source.
|
|
108
|
+
#
|
|
109
|
+
# An earlier version hand-built the qualified form instead (`"#{nesting}::#{bare}"`) on the reasoning
|
|
110
|
+
# that over-approximating which of absolute / relative / top-level a name is could only fail to remove
|
|
111
|
+
# a candidate. That was wrong, and it resurrected the very defect #363 fixed. `include Alba::Resource`
|
|
112
|
+
# inside `class SignageResource` produced `SignageResource::Alba::Resource`; the graph resolves a
|
|
113
|
+
# reference to a member as a reference to its owner, so it peeled that to `SignageResource::Alba` and
|
|
114
|
+
# then to `SignageResource` — a declaration. The reference is file-level, so the class rooted ITSELF,
|
|
115
|
+
# and three genuinely dead classes stayed out of the report on the project where this was found.
|
|
116
|
+
#
|
|
117
|
+
# Passing the real nesting cannot do that: the walk tries the qualified candidate, misses, and the peel
|
|
118
|
+
# then applies to the written name alone.
|
|
119
|
+
def record(type_name, nesting, out)
|
|
120
|
+
return if type_name.nil?
|
|
121
|
+
|
|
122
|
+
bare = type_name.to_s.delete_prefix("::")
|
|
123
|
+
return if bare.empty?
|
|
124
|
+
|
|
125
|
+
out << [bare, nesting.dup.freeze]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def segments(type_name)
|
|
129
|
+
type_name.to_s.delete_prefix("::").split("::")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|