rigortype 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/array.yml +11 -24
  4. data/data/builtins/ruby_core/enumerable.yml +4 -8
  5. data/data/builtins/ruby_core/exception.yml +6 -9
  6. data/data/builtins/ruby_core/hash.yml +13 -20
  7. data/data/builtins/ruby_core/io.yml +6 -9
  8. data/data/builtins/ruby_core/numeric.yml +2 -3
  9. data/data/builtins/ruby_core/proc.yml +4 -8
  10. data/data/builtins/ruby_core/re.yml +6 -9
  11. data/data/builtins/ruby_core/set.yml +2 -3
  12. data/data/builtins/ruby_core/string.yml +4 -7
  13. data/data/builtins/ruby_core/struct.yml +0 -6
  14. data/data/builtins/ruby_core/time.yml +3 -6
  15. data/data/effects/core.yml +843 -0
  16. data/data/effects/registry.yml +67 -0
  17. data/docs/handbook/07-rbs-and-extended.md +106 -5
  18. data/docs/manual/02-cli-reference.md +311 -1
  19. data/docs/manual/03-configuration.md +84 -1
  20. data/docs/manual/04-diagnostics.md +14 -2
  21. data/docs/manual/16-rbs-extended-annotations.md +77 -0
  22. data/docs/manual/18-removing-dead-code.md +326 -0
  23. data/docs/manual/README.md +5 -0
  24. data/docs/manual/plugins/rigor-actionmailer.md +5 -0
  25. data/docs/manual/plugins/rigor-activejob.md +47 -0
  26. data/docs/manual/plugins/rigor-factorybot.md +16 -0
  27. data/docs/manual/plugins/rigor-pundit.md +26 -0
  28. data/docs/manual/plugins/rigor-rails-routes.md +41 -2
  29. data/docs/manual/plugins/rigor-rspec-rails.md +5 -0
  30. data/docs/manual/plugins/rigor-rspec.md +8 -0
  31. data/docs/manual/plugins/rigor-sidekiq.md +54 -0
  32. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
  33. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  34. data/lib/rigor/analysis/check_rules/rule_ids.rb +28 -2
  35. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  36. data/lib/rigor/analysis/check_rules.rb +62 -22
  37. data/lib/rigor/analysis/incremental_session.rb +140 -6
  38. data/lib/rigor/analysis/reachability/graph.rb +244 -0
  39. data/lib/rigor/analysis/reachability/plugin_roots.rb +164 -0
  40. data/lib/rigor/analysis/reachability/project_files.rb +58 -0
  41. data/lib/rigor/analysis/reachability/scan.rb +241 -0
  42. data/lib/rigor/analysis/reachability/signature_scan.rb +134 -0
  43. data/lib/rigor/analysis/rule_catalog.rb +163 -1
  44. data/lib/rigor/analysis/run_cache_key.rb +16 -0
  45. data/lib/rigor/analysis/runner/declaration_position.rb +54 -0
  46. data/lib/rigor/analysis/runner/effect_annotation_residual_pass.rb +108 -0
  47. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +358 -0
  48. data/lib/rigor/analysis/runner/envelope_messages.rb +61 -0
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +68 -10
  50. data/lib/rigor/analysis/runner.rb +420 -10
  51. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  52. data/lib/rigor/analysis/worker_session.rb +58 -2
  53. data/lib/rigor/bleeding_edge.rb +22 -0
  54. data/lib/rigor/cache/incremental_snapshot.rb +24 -5
  55. data/lib/rigor/cli/check_command.rb +13 -1
  56. data/lib/rigor/cli/check_runner_factory.rb +4 -1
  57. data/lib/rigor/cli/effects_command.rb +131 -0
  58. data/lib/rigor/cli/effects_diff_renderer.rb +123 -0
  59. data/lib/rigor/cli/effects_explain_renderer.rb +68 -0
  60. data/lib/rigor/cli/effects_renderer.rb +54 -0
  61. data/lib/rigor/cli/effects_report.rb +53 -0
  62. data/lib/rigor/cli/effects_snapshot_command.rb +256 -0
  63. data/lib/rigor/cli/unused_command.rb +288 -0
  64. data/lib/rigor/cli.rb +16 -0
  65. data/lib/rigor/configuration/severity_profile.rb +23 -0
  66. data/lib/rigor/configuration.rb +296 -14
  67. data/lib/rigor/effects/attribution.rb +76 -0
  68. data/lib/rigor/effects/catalog.rb +275 -0
  69. data/lib/rigor/effects/collector.rb +182 -0
  70. data/lib/rigor/effects/config_envelopes.rb +185 -0
  71. data/lib/rigor/effects/discharge.rb +69 -0
  72. data/lib/rigor/effects/effect_table.rb +92 -0
  73. data/lib/rigor/effects/entry_points.rb +93 -0
  74. data/lib/rigor/effects/envelope.rb +86 -0
  75. data/lib/rigor/effects/envelope_check.rb +172 -0
  76. data/lib/rigor/effects/envelope_index.rb +157 -0
  77. data/lib/rigor/effects/file_collection.rb +160 -0
  78. data/lib/rigor/effects/framework_units.rb +167 -0
  79. data/lib/rigor/effects/identity.rb +104 -0
  80. data/lib/rigor/effects/label.rb +77 -0
  81. data/lib/rigor/effects/label_intent.rb +73 -0
  82. data/lib/rigor/effects/label_set.rb +136 -0
  83. data/lib/rigor/effects/liskov_check.rb +167 -0
  84. data/lib/rigor/effects/local_ownership.rb +132 -0
  85. data/lib/rigor/effects/method_key.rb +40 -0
  86. data/lib/rigor/effects/mutation_classifier.rb +92 -0
  87. data/lib/rigor/effects/narrowing.rb +202 -0
  88. data/lib/rigor/effects/origin.rb +66 -0
  89. data/lib/rigor/effects/path_finder.rb +89 -0
  90. data/lib/rigor/effects/plugin_facts.rb +335 -0
  91. data/lib/rigor/effects/propagator.rb +275 -0
  92. data/lib/rigor/effects/registry.rb +193 -0
  93. data/lib/rigor/effects/scanner.rb +290 -0
  94. data/lib/rigor/effects/signature_sources.rb +74 -0
  95. data/lib/rigor/effects/snapshot.rb +380 -0
  96. data/lib/rigor/effects/snapshot_diff.rb +265 -0
  97. data/lib/rigor/effects/summary.rb +154 -0
  98. data/lib/rigor/effects/taint_cause.rb +38 -0
  99. data/lib/rigor/effects/unit_scan.rb +572 -0
  100. data/lib/rigor/effects/unknown_label_check.rb +86 -0
  101. data/lib/rigor/effects/unknown_label_report.rb +59 -0
  102. data/lib/rigor/effects.rb +47 -0
  103. data/lib/rigor/environment/rbs_loader.rb +34 -0
  104. data/lib/rigor/environment.rb +8 -1
  105. data/lib/rigor/flow_contribution/element.rb +1 -0
  106. data/lib/rigor/flow_contribution/merge_result.rb +5 -3
  107. data/lib/rigor/flow_contribution/merger.rb +16 -1
  108. data/lib/rigor/flow_contribution.rb +20 -4
  109. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  110. data/lib/rigor/inference/expression_typer.rb +74 -17
  111. data/lib/rigor/inference/fork_map.rb +45 -11
  112. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +147 -17
  113. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +97 -2
  114. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  115. data/lib/rigor/inference/optimistic_origin.rb +68 -3
  116. data/lib/rigor/inference/pre_eval_constants.rb +189 -0
  117. data/lib/rigor/inference/scope_indexer.rb +134 -31
  118. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  119. data/lib/rigor/inference/statement_evaluator.rb +25 -12
  120. data/lib/rigor/plugin/base.rb +24 -0
  121. data/lib/rigor/plugin/effect_attribution.rb +208 -0
  122. data/lib/rigor/plugin/effect_edge.rb +101 -0
  123. data/lib/rigor/plugin/effect_entry_points.rb +51 -0
  124. data/lib/rigor/plugin/first_party.rb +57 -0
  125. data/lib/rigor/plugin/isolation.rb +4 -1
  126. data/lib/rigor/plugin/manifest.rb +103 -3
  127. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  128. data/lib/rigor/plugin/registry.rb +48 -0
  129. data/lib/rigor/plugin.rb +1 -0
  130. data/lib/rigor/protection/closure_kill_oracle.rb +20 -1
  131. data/lib/rigor/rbs_extended/envelope_scanner.rb +160 -0
  132. data/lib/rigor/rbs_extended.rb +168 -0
  133. data/lib/rigor/reflection.rb +131 -30
  134. data/lib/rigor/scope.rb +62 -2
  135. data/lib/rigor/source/node_walker.rb +12 -0
  136. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  137. data/lib/rigor/type/nominal.rb +5 -2
  138. data/lib/rigor/type/singleton.rb +4 -1
  139. data/lib/rigor/version.rb +1 -1
  140. data/lib/rigor.rb +1 -0
  141. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +90 -0
  142. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +7 -1
  143. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/effects.rb +85 -0
  144. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +8 -1
  145. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +152 -0
  146. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +9 -1
  147. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +156 -0
  148. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +113 -0
  149. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +68 -3
  150. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +186 -0
  151. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +12 -1
  152. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +145 -0
  153. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/effects.rb +77 -0
  154. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -1
  155. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +143 -0
  156. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +9 -1
  157. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +385 -28
  158. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +30 -2
  159. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +151 -0
  160. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +46 -3
  161. data/plugins/rigor-rails/lib/rigor-rails.rb +1 -0
  162. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/effects.rb +47 -0
  163. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +7 -1
  164. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/acronyms.rb +81 -0
  165. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +13 -1
  166. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +202 -15
  167. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +43 -3
  168. data/plugins/rigor-railties/lib/rigor/plugin/railties/effects.rb +171 -0
  169. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +62 -0
  170. data/plugins/rigor-railties/lib/rigor-railties.rb +3 -0
  171. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +122 -0
  172. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +56 -3
  173. data/sig/rigor/analysis/fact_store.rbs +1 -0
  174. data/sig/rigor/inference.rbs +2 -0
  175. data/sig/rigor/rbs_extended.rbs +6 -0
  176. data/sig/rigor/scope.rbs +4 -0
  177. data/sig/rigor.rbs +21 -1
  178. data/skills/rigor-unused-adjudicate/SKILL.md +90 -0
  179. metadata +83 -1
@@ -0,0 +1,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
@@ -660,6 +660,8 @@ module Rigor
660
660
  "The token resolves (canonical id, legacy alias, `all`, family wildcard, known engine id).",
661
661
  "The token starts with `plugin.` — plugins load dynamically, so their rule vocabulary cannot " \
662
662
  "be enumerated statically and under-warning is the FP-safe direction.",
663
+ "The marker does not open the comment (documentation prose quoting the syntax, a doubled " \
664
+ "`##` comment, or an `=begin` block) — that is not parsed as a suppression either.",
663
665
  "The comment merely mentions the marker followed by non-token text (documentation prose " \
664
666
  "like \"`# rigor:disable <rule>` comments\") — that is not parsed as a suppression either."
665
667
  ],
@@ -684,6 +686,7 @@ module Rigor
684
686
  does_not_fire_when: [
685
687
  "At least one token follows the marker (each token is then checked by " \
686
688
  "`suppression.unknown-rule` instead).",
689
+ "The marker does not open the comment (documentation prose quoting the syntax).",
687
690
  "Non-token text follows the marker (documentation prose mentioning the syntax)."
688
691
  ],
689
692
  suppression: "Complete the marker (`# rigor:disable <rule>` / `all`) or delete it; " \
@@ -725,12 +728,170 @@ module Rigor
725
728
  since: "0.3.0"
726
729
  ),
727
730
 
731
+ CheckRules::RULE_EFFECT_ENVELOPE_EXCEEDED => Entry.new(
732
+ id: CheckRules::RULE_EFFECT_ENVELOPE_EXCEEDED,
733
+ summary: "A method performs an effect its declared envelope does not admit.",
734
+ fires_when: [
735
+ "The project's `.rigor.yml` carries an `effects:` block and `effects.check` is on (it " \
736
+ "defaults to on when the block is present).",
737
+ "The method's RBS carries an envelope — `%a{pure}` (the empty bound) or " \
738
+ "`%a{rigor:v1:effect <labels>}` — written on the method, or on its class / module and " \
739
+ "distributed to it.",
740
+ "The method's PROVEN effect labels — its own body plus the transitive closure over the " \
741
+ "project methods it calls — include a label no member of the bound subsumes."
742
+ ],
743
+ does_not_fire_when: [
744
+ "No `effects:` block is configured, or `effects.check: false` is set — an annotation " \
745
+ "alone never turns the check on.",
746
+ "The exceeding label is `mutate.local`: mutating what the frame itself allocated and " \
747
+ "never let escape is tolerated by every envelope, `%a{pure}` included.",
748
+ "The label is only suspected rather than proven — an unresolved or dynamic call taints " \
749
+ "the summary's exhaustiveness bit and contributes nothing to the proven lane, so " \
750
+ "\"possibly more\" never produces a finding.",
751
+ "The envelope names a label the effect registry does not recognise (a typo, a retired " \
752
+ "spelling): the whole tag then reads as unbounded, which suppresses findings rather " \
753
+ "than inventing them.",
754
+ "The envelope was written on a supertype rather than on this class — the inherited-bound " \
755
+ "(Liskov) reading is `effect.liskov-widened`.",
756
+ "The envelope was written outside the project's own `signature_paths:` RBS (core, a " \
757
+ "gem's shipped RBS): only project-authored envelopes are checked. Such an envelope is " \
758
+ "still imported as a `≤` bound at calls INTO it, which produces no finding."
759
+ ],
760
+ suppression: "`# rigor:disable effect.envelope-exceeded` on the Ruby `def` line (the " \
761
+ "diagnostic is positioned there, not on the `.rbs` line), or " \
762
+ "`disable: [\"effect.envelope-exceeded\"]` in `.rigor.yml`. Widening or " \
763
+ "removing the envelope is the real fix.",
764
+ severity_authored: :warning,
765
+ severity_by_profile: { lenient: :warning, balanced: :warning, strict: :error },
766
+ # FP-safe by two accepted constructions at once: opt-in by author directive (the envelope IS
767
+ # the directive, so a firing is never unsolicited — the `conforms-to` construction) and
768
+ # as-strict-as-proven (the proven lane only; taint never fires). What it costs to be wrong is
769
+ # bounded by the author having asked the question.
770
+ evidence_tier: :high,
771
+ since: "0.3.4"
772
+ ),
773
+
774
+ CheckRules::RULE_EFFECT_LISKOV_WIDENED => Entry.new(
775
+ id: CheckRules::RULE_EFFECT_LISKOV_WIDENED,
776
+ summary: "An override performs or declares an effect the envelope it inherits does not admit.",
777
+ fires_when: [
778
+ "The project's `.rigor.yml` carries an `effects:` block and `effects.check` is on (it " \
779
+ "defaults to on when the block is present).",
780
+ "A method the project defines redefines a method of a SUPERCLASS, and that ancestor's " \
781
+ "definition carries an envelope — written on it, distributed from its class, or put " \
782
+ "there by an `effects.envelopes:` convention. The nearest enveloped ancestor wins.",
783
+ "The override declares NO envelope of its own and its PROVEN effect labels — its body " \
784
+ "plus the transitive closure over the project methods it calls — include a label the " \
785
+ "inherited bound does not admit.",
786
+ "Or the override declares its OWN envelope and that bound is wider than the inherited " \
787
+ "one. This half is proven-independent: two authored bounds, compared by subsumption."
788
+ ],
789
+ does_not_fire_when: [
790
+ "No `effects:` block is configured, or `effects.check: false` is set.",
791
+ "Nobody wrote an envelope on the overridden method. The check is both-sides-authored: " \
792
+ "an override alone can never produce it.",
793
+ "The override is purer than the bound it inherits — that is the whole point of an upper " \
794
+ "bound, and a narrower envelope on an override is correct by construction.",
795
+ "The relation is a module include rather than a subclass. An includer's own `def` sits " \
796
+ "AHEAD of the module's in Ruby's ancestry rather than under it, so the substitutability " \
797
+ "argument that licenses this check does not apply.",
798
+ "The exceeding label is `mutate.local`, or the label is only suspected rather than " \
799
+ "proven: an unresolved or dynamic call taints exhaustiveness and contributes nothing to " \
800
+ "the proven lane.",
801
+ "The ancestor's envelope names a label the registry does not recognise: the whole tag " \
802
+ "reads as unbounded, which suppresses findings rather than inventing them."
803
+ ],
804
+ suppression: "`# rigor:disable effect.liskov-widened` on the override's Ruby `def` line, or " \
805
+ "`disable: [\"effect.liskov-widened\"]` in `.rigor.yml`. Widening the " \
806
+ "ancestor's envelope — or moving the effect out of the override — is the real " \
807
+ "fix.",
808
+ severity_authored: :warning,
809
+ severity_by_profile: { lenient: :warning, balanced: :warning, strict: :error },
810
+ # Both-sides-authored in the ADR-35 sense — an envelope on the ancestor is the directive, and
811
+ # the override is a `def` the project wrote — and as strict as proven on the half that reads a
812
+ # body at all. The other half compares two authored bounds and reads nothing inferred.
813
+ evidence_tier: :high,
814
+ since: "0.3.4"
815
+ ),
816
+
817
+ CheckRules::RULE_EFFECT_UNKNOWN_LABEL => Entry.new(
818
+ id: CheckRules::RULE_EFFECT_UNKNOWN_LABEL,
819
+ summary: "An effect declaration names a label the registry does not recognise, so it " \
820
+ "bounds nothing.",
821
+ fires_when: [
822
+ "The project's `.rigor.yml` carries an `effects:` block and `effects.check` is on — the " \
823
+ "same gate as `effect.envelope-exceeded`, because opting into envelope enforcement is " \
824
+ "what turns on the diagnostic that says an envelope stopped enforcing.",
825
+ "A `%a{rigor:v1:effect <labels>}` envelope — in `.rbs`, or written as an rbs-inline " \
826
+ "`# @rbs %a{…}` comment — names a token the effect registry does not know after plugin " \
827
+ "load, which makes the WHOLE tag read as unbounded (⊤), or an `effects.tolerated:` " \
828
+ "entry names one, which then tolerates nothing.",
829
+ "Label intent is evident from one of four signals: the spelling is within two edits of " \
830
+ "a known label, another member of the same list is known, the token carries two or " \
831
+ "more dot-separated segments, or the registry's retired table names it."
832
+ ],
833
+ does_not_fire_when: [
834
+ "No `effects:` block is configured, or `effects.check: false` is set.",
835
+ "The unrecognised token is a lone far-off word (`database`): a vocabulary is open by " \
836
+ "design, so a bare word nothing resembles is as likely to be a label this project has " \
837
+ "not registered as it is a typo. The tag still reads ⊤ — silence here is about the " \
838
+ "diagnostic, never about the reading.",
839
+ "The tag is malformed rather than unrecognised (`io/db`, an empty list): a grammar " \
840
+ "violation is a different condition, reported through the `RBS::Extended` conflict " \
841
+ "channel.",
842
+ "The label is registered — by the shared registry, by `effects.labels:`, or by a " \
843
+ "plugin that owns its root."
844
+ ],
845
+ suppression: "`disable: [\"effect.unknown-label\"]` in `.rigor.yml`, or a baseline entry. " \
846
+ "A `# rigor:disable` comment works only where the diagnostic lands in a " \
847
+ "`.rb` file (an rbs-inline annotation); Rigor does not read suppression " \
848
+ "comments out of `.rbs` or `.rigor.yml`. Fixing the spelling is the real fix.",
849
+ severity_authored: :info,
850
+ severity_by_profile: { lenient: :info, balanced: :info, strict: :warning },
851
+ # Syntactic and author-directed at once: the token is provably outside the vocabulary, and
852
+ # the author wrote the declaration that names it. The intent gate is what keeps an open
853
+ # vocabulary from turning into noise.
854
+ evidence_tier: :high,
855
+ since: "0.3.4"
856
+ ),
857
+
858
+ CheckRules::RULE_EFFECT_ANNOTATIONS_UNCHECKED => Entry.new(
859
+ id: CheckRules::RULE_EFFECT_ANNOTATIONS_UNCHECKED,
860
+ summary: "Effect annotations are present, but no `effects:` block enables anything that " \
861
+ "reads them.",
862
+ fires_when: [
863
+ "The project's own `signature_paths:` RBS carries a `%a{pure}` or " \
864
+ "`%a{rigor:v1:effect …}` annotation.",
865
+ "`.rigor.yml` carries no `effects:` block, so effect collection never runs and nothing " \
866
+ "checks those annotations. One diagnostic per run, positioned at the first such " \
867
+ "annotation."
868
+ ],
869
+ does_not_fire_when: [
870
+ "An `effects:` block is present — including `effects: {}` and `effects: {check: false}`, " \
871
+ "both of which are deliberate answers to the question this asks.",
872
+ "No project-authored effect annotation exists.",
873
+ "The annotation is written only as an rbs-inline `# @rbs %a{…}` comment and this run " \
874
+ "did not already have an RBS environment at hand: detecting it would mean building one " \
875
+ "on a surface that must stay free, so the residual stays quiet rather than paying for " \
876
+ "an `:info`."
877
+ ],
878
+ suppression: "Add an `effects:` block (`effects: {}` enables collection), or " \
879
+ "`disable: [\"effect.annotations-unchecked\"]` in `.rigor.yml` to keep the " \
880
+ "annotations documentary.",
881
+ severity_authored: :info,
882
+ severity_by_profile: { lenient: :info, balanced: :info, strict: :info },
883
+ # Purely a presence report about the project's own files — nothing is inferred, so there is
884
+ # no proof to be wrong about.
885
+ evidence_tier: nil,
886
+ since: "0.3.4"
887
+ ),
888
+
728
889
  CheckRules::RULE_SUPPRESSION_UNKNOWN_MARKER => Entry.new(
729
890
  id: CheckRules::RULE_SUPPRESSION_UNKNOWN_MARKER,
730
891
  summary: "A comment uses a suppression marker Rigor does not recognise " \
731
892
  "(`rigor:disable-next-line`, `rigor:enable`, ...).",
732
893
  fires_when: [
733
- "A comment carries `rigor:disable-<suffix>` with a suffix other than `file`, or " \
894
+ "A comment OPENS with `rigor:disable-<suffix>` for a suffix other than `file`, or with " \
734
895
  "`rigor:enable[-<suffix>]` — typically the RuboCop reflex `# rigor:disable-next-line " \
735
896
  "<rule>` — followed by nothing or a rule-list-shaped remainder.",
736
897
  "Such a marker is invisible to the whole suppression grammar, so it silently suppresses " \
@@ -740,6 +901,7 @@ module Rigor
740
901
  does_not_fire_when: [
741
902
  "The marker is one of the two recognised forms (their tokens are then checked by " \
742
903
  "`suppression.unknown-rule` / `suppression.empty` instead).",
904
+ "The marker does not open the comment (documentation prose quoting the spelling).",
743
905
  "Non-token text follows the marker (documentation prose mentioning the spelling)."
744
906
  ],
745
907
  suppression: "Rewrite as `# rigor:disable <rules>` on the offending line (Rigor has no " \
@@ -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:)
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ class Runner
6
+ # Where an `effect.unknown-label` finding is positioned (ADR-103 WD14; #384).
7
+ #
8
+ # The declaration, not the method: this is a fact about what the author wrote, and the fix is on
9
+ # that line. A `.rbs` position is the right answer here even though its sibling deliberately avoids
10
+ # one (the `rbs_extended.unsatisfied-conformance` precedent) — there is no Ruby `def` that could
11
+ # carry the typo. A value written in configuration has no location at all and lands at
12
+ # `.rigor.yml:1`, the `rbs.coverage.quarantined-signature` precedent.
13
+ module DeclarationPosition
14
+ CONFIG_PATH = ".rigor.yml"
15
+ RUBY_EXTENSION = ".rb"
16
+ private_constant :RUBY_EXTENSION
17
+
18
+ module_function
19
+
20
+ # @param finding [#location, #spelling]
21
+ # @param sources [Hash{String => String}] in-memory sources, for the buffer-backed run path
22
+ # @return [Array(String, Integer)] `[path, line]`
23
+ def of(finding, sources: {})
24
+ location = finding.location
25
+ return [CONFIG_PATH, 1] if location.nil?
26
+
27
+ path, _, raw_line = location.rpartition(":")
28
+ return [CONFIG_PATH, 1] if path.empty?
29
+
30
+ line = raw_line.to_i
31
+ line = 1 unless line.positive?
32
+ return [path, line] unless path.end_with?(RUBY_EXTENSION)
33
+
34
+ [path, inline_line(path, finding.spelling, sources) || line]
35
+ end
36
+
37
+ # rbs-inline's writer re-emits the author's own comment block ABOVE the annotation it generates,
38
+ # so a line number read out of the synthesized buffer drifts from the `.rb` line the author
39
+ # actually wrote — by the length of every method body above it. The annotation's own text is
40
+ # unique enough to find again, so the Ruby file is what answers. One read, and only when a
41
+ # finding already exists.
42
+ def inline_line(path, spelling, sources)
43
+ return nil if spelling.nil?
44
+
45
+ source = sources[path] || File.read(path)
46
+ source.each_line.with_index(1) { |line, number| return number if line.include?(spelling) }
47
+ nil
48
+ rescue StandardError
49
+ nil
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end