rigortype 0.3.3 → 0.3.5

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 (171) 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 +860 -0
  16. data/data/effects/registry.yml +95 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +359 -2
  19. data/docs/manual/03-configuration.md +101 -1
  20. data/docs/manual/04-diagnostics.md +8 -3
  21. data/docs/manual/11-ci.md +37 -0
  22. data/docs/manual/12-caching.md +39 -0
  23. data/docs/manual/16-rbs-extended-annotations.md +90 -0
  24. data/docs/manual/18-removing-dead-code.md +326 -0
  25. data/docs/manual/19-effect-labels.md +671 -0
  26. data/docs/manual/README.md +10 -0
  27. data/docs/manual/ci-templates/README.md +9 -0
  28. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  29. data/docs/manual/plugins/rigor-activejob.md +47 -0
  30. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  31. data/docs/manual/plugins/rigor-pundit.md +26 -0
  32. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  33. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  34. data/docs/manual/plugins/rigor-rspec.md +8 -0
  35. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  36. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  37. data/lib/rigor/analysis/incremental_session.rb +140 -6
  38. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  39. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  40. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  41. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  42. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  43. data/lib/rigor/analysis/rule_catalog.rb +168 -3
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/run_cache_probe.rb +69 -1
  46. data/lib/rigor/analysis/runner/declaration_position.rb +38 -0
  47. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  48. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  49. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  50. data/lib/rigor/analysis/runner/pool_coordinator.rb +93 -10
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +7 -4
  52. data/lib/rigor/analysis/runner.rb +429 -10
  53. data/lib/rigor/analysis/worker_session.rb +60 -2
  54. data/lib/rigor/bleeding_edge.rb +22 -0
  55. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  56. data/lib/rigor/cli/check_command.rb +13 -1
  57. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  58. data/lib/rigor/cli/effects_command.rb +245 -0
  59. data/lib/rigor/cli/effects_diff_renderer.rb +127 -0
  60. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  61. data/lib/rigor/cli/effects_renderer.rb +92 -0
  62. data/lib/rigor/cli/effects_report.rb +163 -0
  63. data/lib/rigor/cli/effects_snapshot_command.rb +275 -0
  64. data/lib/rigor/cli/unused_command.rb +288 -0
  65. data/lib/rigor/cli.rb +28 -1
  66. data/lib/rigor/configuration/severity_profile.rb +23 -0
  67. data/lib/rigor/configuration.rb +322 -28
  68. data/lib/rigor/configuration_error.rb +20 -0
  69. data/lib/rigor/effects/attribution.rb +76 -0
  70. data/lib/rigor/effects/catalog.rb +275 -0
  71. data/lib/rigor/effects/collector.rb +219 -0
  72. data/lib/rigor/effects/config_envelopes.rb +185 -0
  73. data/lib/rigor/effects/discharge.rb +69 -0
  74. data/lib/rigor/effects/effect_table.rb +92 -0
  75. data/lib/rigor/effects/entry_points.rb +139 -0
  76. data/lib/rigor/effects/envelope.rb +86 -0
  77. data/lib/rigor/effects/envelope_check.rb +172 -0
  78. data/lib/rigor/effects/envelope_index.rb +157 -0
  79. data/lib/rigor/effects/file_collection.rb +174 -0
  80. data/lib/rigor/effects/framework_units.rb +222 -0
  81. data/lib/rigor/effects/identity.rb +104 -0
  82. data/lib/rigor/effects/inline_anchor.rb +134 -0
  83. data/lib/rigor/effects/label.rb +77 -0
  84. data/lib/rigor/effects/label_intent.rb +73 -0
  85. data/lib/rigor/effects/label_set.rb +136 -0
  86. data/lib/rigor/effects/liskov_check.rb +167 -0
  87. data/lib/rigor/effects/local_ownership.rb +132 -0
  88. data/lib/rigor/effects/method_key.rb +40 -0
  89. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  90. data/lib/rigor/effects/narrowing.rb +202 -0
  91. data/lib/rigor/effects/origin.rb +66 -0
  92. data/lib/rigor/effects/path_finder.rb +89 -0
  93. data/lib/rigor/effects/plugin_facts.rb +384 -0
  94. data/lib/rigor/effects/propagator.rb +335 -0
  95. data/lib/rigor/effects/registry.rb +200 -0
  96. data/lib/rigor/effects/scanner.rb +302 -0
  97. data/lib/rigor/effects/signature_sources.rb +90 -0
  98. data/lib/rigor/effects/snapshot.rb +396 -0
  99. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  100. data/lib/rigor/effects/summary.rb +154 -0
  101. data/lib/rigor/effects/taint_cause.rb +39 -0
  102. data/lib/rigor/effects/unit_scan.rb +647 -0
  103. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  104. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  105. data/lib/rigor/effects.rb +47 -0
  106. data/lib/rigor/environment/rbs_loader.rb +34 -0
  107. data/lib/rigor/environment.rb +8 -1
  108. data/lib/rigor/flow_contribution/element.rb +1 -0
  109. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  110. data/lib/rigor/flow_contribution/merger.rb +16 -1
  111. data/lib/rigor/flow_contribution.rb +20 -4
  112. data/lib/rigor/inference/expression_typer.rb +18 -4
  113. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  114. data/lib/rigor/inference/scope_indexer.rb +15 -3
  115. data/lib/rigor/plugin/base.rb +28 -0
  116. data/lib/rigor/plugin/box.rb +18 -2
  117. data/lib/rigor/plugin/effect_ancestry.rb +80 -0
  118. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  119. data/lib/rigor/plugin/effect_edge.rb +101 -0
  120. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  121. data/lib/rigor/plugin/first_party.rb +57 -0
  122. data/lib/rigor/plugin/isolation.rb +4 -1
  123. data/lib/rigor/plugin/manifest.rb +127 -3
  124. data/lib/rigor/plugin/registry.rb +55 -0
  125. data/lib/rigor/plugin.rb +1 -0
  126. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  127. data/lib/rigor/rbs_extended.rb +188 -0
  128. data/lib/rigor/reflection.rb +131 -30
  129. data/lib/rigor/version.rb +1 -1
  130. data/lib/rigor.rb +1 -0
  131. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  133. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  134. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  135. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  136. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  137. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  138. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  139. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  140. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  141. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  142. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  143. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  144. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  145. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  146. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  147. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +401 -29
  148. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +27 -0
  149. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  150. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  151. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  152. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  153. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  154. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  155. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  156. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  157. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  158. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  159. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  160. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  161. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  162. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +56 -1
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/effects.rb +75 -0
  164. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  165. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +68 -3
  166. data/sig/rigor/analysis/fact_store.rbs +1 -0
  167. data/sig/rigor/inference.rbs +2 -0
  168. data/sig/rigor/rbs_extended.rbs +6 -0
  169. data/sig/rigor.rbs +17 -1
  170. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  171. metadata +84 -1
@@ -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
@@ -36,9 +36,16 @@ module Rigor
36
36
  module RuleCatalog # rubocop:disable Metrics/ModuleLength
37
37
  # Stable documentation home for a built-in rule. `documentation_url` appends a per-rule fragment that
38
38
  # resolves to the rule's anchor in the published diagnostics catalogue; the page itself points at
39
- # `rigor explain <rule>` as the authoritative per-rule reference. Mirrors the gemspec
40
- # `documentation_uri` URL scheme (`…/tree/main`).
41
- DOCUMENTATION_BASE = "https://github.com/rigortype/rigor/blob/main/docs/manual/04-diagnostics.md"
39
+ # `rigor explain <rule>` as the authoritative per-rule reference.
40
+ #
41
+ # The canonical docs host, deliberately NOT a `github.com/…/blob/<ref>/…` path (ADR-65 amendment,
42
+ # #438): a git ref inside a frozen public contract is a mutable component, and the one that was
43
+ # baked in here — `main`, a branch this repository has never had — made every emitted URL 404 from
44
+ # the day the field shipped. A branch name rots on a rename and a tag only resolves once that tag
45
+ # is pushed (so every unreleased build would emit 404s); the published site carries no ref at all.
46
+ # The site renders `docs/manual/04-diagnostics.md` verbatim, `<a id="rule-…">` anchors included, so
47
+ # the fragment half of the contract is unchanged.
48
+ DOCUMENTATION_BASE = "https://rigor.typedduck.fail/manual/04-diagnostics/"
42
49
 
43
50
  class Entry < Data.define(:id, :summary, :fires_when, :does_not_fire_when,
44
51
  :suppression, :severity_authored, :severity_by_profile,
@@ -728,6 +735,164 @@ module Rigor
728
735
  since: "0.3.0"
729
736
  ),
730
737
 
738
+ CheckRules::RULE_EFFECT_ENVELOPE_EXCEEDED => Entry.new(
739
+ id: CheckRules::RULE_EFFECT_ENVELOPE_EXCEEDED,
740
+ summary: "A method performs an effect its declared envelope does not admit.",
741
+ fires_when: [
742
+ "The project's `.rigor.yml` carries an `effects:` block and `effects.check` is on (it " \
743
+ "defaults to on when the block is present).",
744
+ "The method's RBS carries an envelope — `%a{pure}` (the empty bound) or " \
745
+ "`%a{rigor:v1:effect <labels>}` — written on the method, or on its class / module and " \
746
+ "distributed to it.",
747
+ "The method's PROVEN effect labels — its own body plus the transitive closure over the " \
748
+ "project methods it calls — include a label no member of the bound subsumes."
749
+ ],
750
+ does_not_fire_when: [
751
+ "No `effects:` block is configured, or `effects.check: false` is set — an annotation " \
752
+ "alone never turns the check on.",
753
+ "The exceeding label is `mutate.local`: mutating what the frame itself allocated and " \
754
+ "never let escape is tolerated by every envelope, `%a{pure}` included.",
755
+ "The label is only suspected rather than proven — an unresolved or dynamic call taints " \
756
+ "the summary's exhaustiveness bit and contributes nothing to the proven lane, so " \
757
+ "\"possibly more\" never produces a finding.",
758
+ "The envelope names a label the effect registry does not recognise (a typo, a retired " \
759
+ "spelling): the whole tag then reads as unbounded, which suppresses findings rather " \
760
+ "than inventing them.",
761
+ "The envelope was written on a supertype rather than on this class — the inherited-bound " \
762
+ "(Liskov) reading is `effect.liskov-widened`.",
763
+ "The envelope was written outside the project's own `signature_paths:` RBS (core, a " \
764
+ "gem's shipped RBS): only project-authored envelopes are checked. Such an envelope is " \
765
+ "still imported as a `≤` bound at calls INTO it, which produces no finding."
766
+ ],
767
+ suppression: "`# rigor:disable effect.envelope-exceeded` on the Ruby `def` line (the " \
768
+ "diagnostic is positioned there, not on the `.rbs` line), or " \
769
+ "`disable: [\"effect.envelope-exceeded\"]` in `.rigor.yml`. Widening or " \
770
+ "removing the envelope is the real fix.",
771
+ severity_authored: :warning,
772
+ severity_by_profile: { lenient: :warning, balanced: :warning, strict: :error },
773
+ # FP-safe by two accepted constructions at once: opt-in by author directive (the envelope IS
774
+ # the directive, so a firing is never unsolicited — the `conforms-to` construction) and
775
+ # as-strict-as-proven (the proven lane only; taint never fires). What it costs to be wrong is
776
+ # bounded by the author having asked the question.
777
+ evidence_tier: :high,
778
+ since: "0.3.4"
779
+ ),
780
+
781
+ CheckRules::RULE_EFFECT_LISKOV_WIDENED => Entry.new(
782
+ id: CheckRules::RULE_EFFECT_LISKOV_WIDENED,
783
+ summary: "An override performs or declares an effect the envelope it inherits does not admit.",
784
+ fires_when: [
785
+ "The project's `.rigor.yml` carries an `effects:` block and `effects.check` is on (it " \
786
+ "defaults to on when the block is present).",
787
+ "A method the project defines redefines a method of a SUPERCLASS, and that ancestor's " \
788
+ "definition carries an envelope — written on it, distributed from its class, or put " \
789
+ "there by an `effects.envelopes:` convention. The nearest enveloped ancestor wins.",
790
+ "The override declares NO envelope of its own and its PROVEN effect labels — its body " \
791
+ "plus the transitive closure over the project methods it calls — include a label the " \
792
+ "inherited bound does not admit.",
793
+ "Or the override declares its OWN envelope and that bound is wider than the inherited " \
794
+ "one. This half is proven-independent: two authored bounds, compared by subsumption."
795
+ ],
796
+ does_not_fire_when: [
797
+ "No `effects:` block is configured, or `effects.check: false` is set.",
798
+ "Nobody wrote an envelope on the overridden method. The check is both-sides-authored: " \
799
+ "an override alone can never produce it.",
800
+ "The override is purer than the bound it inherits — that is the whole point of an upper " \
801
+ "bound, and a narrower envelope on an override is correct by construction.",
802
+ "The relation is a module include rather than a subclass. An includer's own `def` sits " \
803
+ "AHEAD of the module's in Ruby's ancestry rather than under it, so the substitutability " \
804
+ "argument that licenses this check does not apply.",
805
+ "The exceeding label is `mutate.local`, or the label is only suspected rather than " \
806
+ "proven: an unresolved or dynamic call taints exhaustiveness and contributes nothing to " \
807
+ "the proven lane.",
808
+ "The ancestor's envelope names a label the registry does not recognise: the whole tag " \
809
+ "reads as unbounded, which suppresses findings rather than inventing them."
810
+ ],
811
+ suppression: "`# rigor:disable effect.liskov-widened` on the override's Ruby `def` line, or " \
812
+ "`disable: [\"effect.liskov-widened\"]` in `.rigor.yml`. Widening the " \
813
+ "ancestor's envelope — or moving the effect out of the override — is the real " \
814
+ "fix.",
815
+ severity_authored: :warning,
816
+ severity_by_profile: { lenient: :warning, balanced: :warning, strict: :error },
817
+ # Both-sides-authored in the ADR-35 sense — an envelope on the ancestor is the directive, and
818
+ # the override is a `def` the project wrote — and as strict as proven on the half that reads a
819
+ # body at all. The other half compares two authored bounds and reads nothing inferred.
820
+ evidence_tier: :high,
821
+ since: "0.3.4"
822
+ ),
823
+
824
+ CheckRules::RULE_EFFECT_UNKNOWN_LABEL => Entry.new(
825
+ id: CheckRules::RULE_EFFECT_UNKNOWN_LABEL,
826
+ summary: "An effect declaration names a label the registry does not recognise, so it " \
827
+ "bounds nothing.",
828
+ fires_when: [
829
+ "The project's `.rigor.yml` carries an `effects:` block and `effects.check` is on — the " \
830
+ "same gate as `effect.envelope-exceeded`, because opting into envelope enforcement is " \
831
+ "what turns on the diagnostic that says an envelope stopped enforcing.",
832
+ "A `%a{rigor:v1:effect <labels>}` envelope — in `.rbs`, or written as an rbs-inline " \
833
+ "`# @rbs %a{…}` comment — names a token the effect registry does not know after plugin " \
834
+ "load, which makes the WHOLE tag read as unbounded (⊤), or an `effects.tolerated:` " \
835
+ "entry names one, which then tolerates nothing.",
836
+ "Label intent is evident from one of four signals: the spelling is within two edits of " \
837
+ "a known label, another member of the same list is known, the token carries two or " \
838
+ "more dot-separated segments, or the registry's retired table names it."
839
+ ],
840
+ does_not_fire_when: [
841
+ "No `effects:` block is configured, or `effects.check: false` is set.",
842
+ "The unrecognised token is a lone far-off word (`database`): a vocabulary is open by " \
843
+ "design, so a bare word nothing resembles is as likely to be a label this project has " \
844
+ "not registered as it is a typo. The tag still reads ⊤ — silence here is about the " \
845
+ "diagnostic, never about the reading.",
846
+ "The tag is malformed rather than unrecognised (`io/db`, an empty list): a grammar " \
847
+ "violation is a different condition, reported through the `RBS::Extended` conflict " \
848
+ "channel.",
849
+ "The label is registered — by the shared registry, by `effects.labels:`, or by a " \
850
+ "plugin that owns its root."
851
+ ],
852
+ suppression: "`disable: [\"effect.unknown-label\"]` in `.rigor.yml`, or a baseline entry. " \
853
+ "A `# rigor:disable` comment works only where the diagnostic lands in a " \
854
+ "`.rb` file (an rbs-inline annotation); Rigor does not read suppression " \
855
+ "comments out of `.rbs` or `.rigor.yml`. Fixing the spelling is the real fix.",
856
+ severity_authored: :info,
857
+ severity_by_profile: { lenient: :info, balanced: :info, strict: :warning },
858
+ # Syntactic and author-directed at once: the token is provably outside the vocabulary, and
859
+ # the author wrote the declaration that names it. The intent gate is what keeps an open
860
+ # vocabulary from turning into noise.
861
+ evidence_tier: :high,
862
+ since: "0.3.4"
863
+ ),
864
+
865
+ CheckRules::RULE_EFFECT_ANNOTATIONS_UNCHECKED => Entry.new(
866
+ id: CheckRules::RULE_EFFECT_ANNOTATIONS_UNCHECKED,
867
+ summary: "Effect annotations are present, but no `effects:` block enables anything that " \
868
+ "reads them.",
869
+ fires_when: [
870
+ "The project's own `signature_paths:` RBS carries a `%a{pure}` or " \
871
+ "`%a{rigor:v1:effect …}` annotation.",
872
+ "`.rigor.yml` carries no `effects:` block, so effect collection never runs and nothing " \
873
+ "checks those annotations. One diagnostic per run, positioned at the first such " \
874
+ "annotation."
875
+ ],
876
+ does_not_fire_when: [
877
+ "An `effects:` block is present — including `effects: {}` and `effects: {check: false}`, " \
878
+ "both of which are deliberate answers to the question this asks.",
879
+ "No project-authored effect annotation exists.",
880
+ "The annotation is written only as an rbs-inline `# @rbs %a{…}` comment and this run " \
881
+ "did not already have an RBS environment at hand: detecting it would mean building one " \
882
+ "on a surface that must stay free, so the residual stays quiet rather than paying for " \
883
+ "an `:info`."
884
+ ],
885
+ suppression: "Add an `effects:` block (`effects: {}` enables collection), or " \
886
+ "`disable: [\"effect.annotations-unchecked\"]` in `.rigor.yml` to keep the " \
887
+ "annotations documentary.",
888
+ severity_authored: :info,
889
+ severity_by_profile: { lenient: :info, balanced: :info, strict: :info },
890
+ # Purely a presence report about the project's own files — nothing is inferred, so there is
891
+ # no proof to be wrong about.
892
+ evidence_tier: nil,
893
+ since: "0.3.4"
894
+ ),
895
+
731
896
  CheckRules::RULE_SUPPRESSION_UNKNOWN_MARKER => Entry.new(
732
897
  id: CheckRules::RULE_SUPPRESSION_UNKNOWN_MARKER,
733
898
  summary: "A comment uses a suppression marker Rigor does not recognise " \
@@ -37,6 +37,17 @@ module Rigor
37
37
 
38
38
  RUN_DIAGNOSTICS_PRODUCER_ID = "analysis.run-diagnostics"
39
39
 
40
+ # ADR-103 WD13 / issue #382 — the whole-run **effects sidecar**: the run's per-file effect
41
+ # collections, keyed by {Effects::Identity.descriptor} (this key descriptor plus the vocabulary
42
+ # version, the catalogue identity and the `effects:` digest) rather than by the descriptor above.
43
+ #
44
+ # A separate producer id, not a second section of the diagnostics entry, and that is the whole of
45
+ # "the diagnostics slot is never invalidated by effects": the two slots cannot share a fate when they
46
+ # do not share a file. It also keeps the ADR-87 boot-slim probe reading exactly the bytes it reads
47
+ # today — it peeks `analysis.run-diagnostics` and finds a plain diagnostics array, whatever a
48
+ # collecting run wrote elsewhere.
49
+ RUN_EFFECTS_PRODUCER_ID = "analysis.run-effects"
50
+
40
51
  # The run-result producer's declared compaction budget (`Cache::Store#evict!` pass 2). Whole-project,
41
52
  # but unlike the `rbs.*` producers several generations can be live at once: the `paths` key slot means
42
53
  # one entry per analyzed-path SET, so `rigor check` over the whole project, over `lib`, and over a
@@ -47,6 +58,11 @@ module Rigor
47
58
  # often an evicted generation is asked for again — see issue #151.
48
59
  GENERATION_CAP = 16
49
60
 
61
+ # The effects sidecar's own compaction budget. One generation per (path set × effects identity), and
62
+ # only a project that opted in writes any at all, so it is sized as the diagnostics cap's shadow: a
63
+ # collecting project's path-set churn is the same churn, and over-evicting costs one recompute.
64
+ EFFECTS_GENERATION_CAP = GENERATION_CAP
65
+
50
66
  # @param rbs_config_entries [Array<Cache::Descriptor::ConfigEntry>] the RBS-derived config slots
51
67
  # (`rbs.libraries` [+ `rbs.virtual_rbs`]). nil on any failure so a malformed key disables the cache.
52
68
  def descriptor(configuration:, files:, explain:, rbs_config_entries:)