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.
Files changed (158) 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 +6 -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/rule_ids.rb +28 -2
  33. data/lib/rigor/analysis/incremental_session.rb +140 -6
  34. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  35. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  36. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  37. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  38. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  39. data/lib/rigor/analysis/rule_catalog.rb +158 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  41. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  42. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  43. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  44. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  45. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  46. data/lib/rigor/analysis/runner.rb +420 -10
  47. data/lib/rigor/analysis/worker_session.rb +58 -2
  48. data/lib/rigor/bleeding_edge.rb +22 -0
  49. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  50. data/lib/rigor/cli/check_command.rb +13 -1
  51. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  52. data/lib/rigor/cli/effects_command.rb +131 -0
  53. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  54. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  55. data/lib/rigor/cli/effects_renderer.rb +54 -0
  56. data/lib/rigor/cli/effects_report.rb +53 -0
  57. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  58. data/lib/rigor/cli/unused_command.rb +288 -0
  59. data/lib/rigor/cli.rb +16 -0
  60. data/lib/rigor/configuration/severity_profile.rb +23 -0
  61. data/lib/rigor/configuration.rb +296 -14
  62. data/lib/rigor/effects/attribution.rb +76 -0
  63. data/lib/rigor/effects/catalog.rb +275 -0
  64. data/lib/rigor/effects/collector.rb +182 -0
  65. data/lib/rigor/effects/config_envelopes.rb +185 -0
  66. data/lib/rigor/effects/discharge.rb +69 -0
  67. data/lib/rigor/effects/effect_table.rb +92 -0
  68. data/lib/rigor/effects/entry_points.rb +93 -0
  69. data/lib/rigor/effects/envelope.rb +86 -0
  70. data/lib/rigor/effects/envelope_check.rb +172 -0
  71. data/lib/rigor/effects/envelope_index.rb +157 -0
  72. data/lib/rigor/effects/file_collection.rb +160 -0
  73. data/lib/rigor/effects/framework_units.rb +167 -0
  74. data/lib/rigor/effects/identity.rb +104 -0
  75. data/lib/rigor/effects/label.rb +77 -0
  76. data/lib/rigor/effects/label_intent.rb +73 -0
  77. data/lib/rigor/effects/label_set.rb +136 -0
  78. data/lib/rigor/effects/liskov_check.rb +167 -0
  79. data/lib/rigor/effects/local_ownership.rb +132 -0
  80. data/lib/rigor/effects/method_key.rb +40 -0
  81. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  82. data/lib/rigor/effects/narrowing.rb +202 -0
  83. data/lib/rigor/effects/origin.rb +66 -0
  84. data/lib/rigor/effects/path_finder.rb +89 -0
  85. data/lib/rigor/effects/plugin_facts.rb +335 -0
  86. data/lib/rigor/effects/propagator.rb +275 -0
  87. data/lib/rigor/effects/registry.rb +193 -0
  88. data/lib/rigor/effects/scanner.rb +290 -0
  89. data/lib/rigor/effects/signature_sources.rb +74 -0
  90. data/lib/rigor/effects/snapshot.rb +380 -0
  91. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  92. data/lib/rigor/effects/summary.rb +154 -0
  93. data/lib/rigor/effects/taint_cause.rb +38 -0
  94. data/lib/rigor/effects/unit_scan.rb +572 -0
  95. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  96. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  97. data/lib/rigor/effects.rb +47 -0
  98. data/lib/rigor/environment/rbs_loader.rb +34 -0
  99. data/lib/rigor/environment.rb +8 -1
  100. data/lib/rigor/flow_contribution/element.rb +1 -0
  101. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  102. data/lib/rigor/flow_contribution/merger.rb +16 -1
  103. data/lib/rigor/flow_contribution.rb +20 -4
  104. data/lib/rigor/inference/expression_typer.rb +18 -4
  105. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  106. data/lib/rigor/inference/scope_indexer.rb +15 -3
  107. data/lib/rigor/plugin/base.rb +24 -0
  108. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  109. data/lib/rigor/plugin/effect_edge.rb +101 -0
  110. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  111. data/lib/rigor/plugin/first_party.rb +57 -0
  112. data/lib/rigor/plugin/isolation.rb +4 -1
  113. data/lib/rigor/plugin/manifest.rb +103 -3
  114. data/lib/rigor/plugin/registry.rb +48 -0
  115. data/lib/rigor/plugin.rb +1 -0
  116. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  117. data/lib/rigor/rbs_extended.rb +168 -0
  118. data/lib/rigor/reflection.rb +131 -30
  119. data/lib/rigor/version.rb +1 -1
  120. data/lib/rigor.rb +1 -0
  121. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  122. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  123. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  124. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  125. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  126. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  127. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  128. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  129. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  130. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  131. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  132. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  133. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  134. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  135. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  136. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  137. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  138. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  139. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  140. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  141. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  142. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  143. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  144. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  145. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  146. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  147. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  148. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  149. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  150. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  151. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  152. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  153. data/sig/rigor/analysis/fact_store.rbs +1 -0
  154. data/sig/rigor/inference.rbs +2 -0
  155. data/sig/rigor/rbs_extended.rbs +6 -0
  156. data/sig/rigor.rbs +17 -1
  157. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  158. 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