rigortype 0.1.1 → 0.1.3

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -0
  3. data/data/builtins/ruby_core/range.yml +6 -4
  4. data/data/builtins/ruby_core/string.yml +15 -10
  5. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +116 -0
  6. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +123 -0
  7. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +118 -0
  8. data/lib/rigor/analysis/check_rules.rb +346 -18
  9. data/lib/rigor/analysis/dependency_source_inference/builder.rb +87 -0
  10. data/lib/rigor/analysis/dependency_source_inference/gem_resolver.rb +72 -0
  11. data/lib/rigor/analysis/dependency_source_inference/index.rb +110 -0
  12. data/lib/rigor/analysis/dependency_source_inference/walker.rb +200 -0
  13. data/lib/rigor/analysis/dependency_source_inference.rb +37 -0
  14. data/lib/rigor/analysis/rule_catalog.rb +343 -0
  15. data/lib/rigor/analysis/runner.rb +96 -6
  16. data/lib/rigor/cache/descriptor.rb +58 -5
  17. data/lib/rigor/cli/diff_command.rb +169 -0
  18. data/lib/rigor/cli/explain_command.rb +129 -0
  19. data/lib/rigor/cli.rb +18 -1
  20. data/lib/rigor/configuration/dependencies.rb +235 -0
  21. data/lib/rigor/configuration/severity_profile.rb +18 -3
  22. data/lib/rigor/configuration.rb +53 -13
  23. data/lib/rigor/environment.rb +16 -4
  24. data/lib/rigor/flow_contribution/merger.rb +4 -0
  25. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +104 -12
  26. data/lib/rigor/inference/method_dispatcher.rb +87 -0
  27. data/lib/rigor/inference/scope_indexer.rb +171 -2
  28. data/lib/rigor/inference/statement_evaluator.rb +65 -1
  29. data/lib/rigor/plugin/io_boundary.rb +92 -19
  30. data/lib/rigor/plugin/manifest.rb +26 -5
  31. data/lib/rigor/plugin/trust_policy.rb +30 -7
  32. data/lib/rigor/scope.rb +30 -5
  33. data/lib/rigor/version.rb +1 -1
  34. data/sig/rigor/environment.rbs +3 -2
  35. data/sig/rigor/scope.rbs +3 -0
  36. metadata +13 -1
@@ -6,6 +6,9 @@ require_relative "../reflection"
6
6
  require_relative "../source/node_walker"
7
7
  require_relative "../type"
8
8
  require_relative "diagnostic"
9
+ require_relative "check_rules/always_truthy_condition_collector"
10
+ require_relative "check_rules/dead_assignment_collector"
11
+ require_relative "check_rules/ivar_write_collector"
9
12
 
10
13
  module Rigor
11
14
  module Analysis
@@ -60,7 +63,12 @@ module Rigor
60
63
  RULE_DUMP_TYPE = "dump.type"
61
64
  RULE_ASSERT_TYPE = "assert.type-mismatch"
62
65
  RULE_ALWAYS_RAISES = "flow.always-raises"
66
+ RULE_UNREACHABLE_BRANCH = "flow.unreachable-branch"
63
67
  RULE_RETURN_TYPE = "def.return-type-mismatch"
68
+ RULE_VISIBILITY_MISMATCH = "def.method-visibility-mismatch"
69
+ RULE_IVAR_WRITE_MISMATCH = "def.ivar-write-mismatch"
70
+ RULE_DEAD_ASSIGNMENT = "flow.dead-assignment"
71
+ RULE_ALWAYS_TRUTHY_CONDITION = "flow.always-truthy-condition"
64
72
 
65
73
  ALL_RULES = [
66
74
  RULE_UNDEFINED_METHOD,
@@ -70,7 +78,12 @@ module Rigor
70
78
  RULE_DUMP_TYPE,
71
79
  RULE_ASSERT_TYPE,
72
80
  RULE_ALWAYS_RAISES,
73
- RULE_RETURN_TYPE
81
+ RULE_UNREACHABLE_BRANCH,
82
+ RULE_DEAD_ASSIGNMENT,
83
+ RULE_ALWAYS_TRUTHY_CONDITION,
84
+ RULE_RETURN_TYPE,
85
+ RULE_VISIBILITY_MISMATCH,
86
+ RULE_IVAR_WRITE_MISMATCH
74
87
  ].freeze
75
88
 
76
89
  # Backward-compat alias table (ADR-8 § "Backward
@@ -88,7 +101,12 @@ module Rigor
88
101
  "possible-nil-receiver" => RULE_NIL_RECEIVER,
89
102
  "dump-type" => RULE_DUMP_TYPE,
90
103
  "assert-type" => RULE_ASSERT_TYPE,
91
- "always-raises" => RULE_ALWAYS_RAISES
104
+ "always-raises" => RULE_ALWAYS_RAISES,
105
+ "unreachable-branch" => RULE_UNREACHABLE_BRANCH,
106
+ "method-visibility-mismatch" => RULE_VISIBILITY_MISMATCH,
107
+ "ivar-write-mismatch" => RULE_IVAR_WRITE_MISMATCH,
108
+ "dead-assignment" => RULE_DEAD_ASSIGNMENT,
109
+ "always-truthy-condition" => RULE_ALWAYS_TRUTHY_CONDITION
92
110
  }.freeze
93
111
 
94
112
  # Family wildcard — a `<family>` token in a suppression
@@ -124,13 +142,20 @@ module Rigor
124
142
  def diagnose(path:, root:, scope_index:, comments: [], disabled_rules: [])
125
143
  diagnostics = []
126
144
  Source::NodeWalker.each(root) do |node|
127
- if node.is_a?(Prism::CallNode)
145
+ case node
146
+ when Prism::CallNode
128
147
  diagnostics.concat(call_node_diagnostics(path, node, scope_index))
129
- elsif node.is_a?(Prism::DefNode)
148
+ when Prism::DefNode
130
149
  return_diagnostic = return_type_mismatch_diagnostic(path, node, scope_index)
131
150
  diagnostics << return_diagnostic if return_diagnostic
151
+ when Prism::IfNode, Prism::UnlessNode
152
+ unreachable = unreachable_branch_diagnostic(path, node, scope_index)
153
+ diagnostics << unreachable if unreachable
132
154
  end
133
155
  end
156
+ diagnostics.concat(always_truthy_condition_diagnostics(path, root, scope_index))
157
+ diagnostics.concat(ivar_write_mismatch_diagnostics(path, root, scope_index))
158
+ diagnostics.concat(dead_assignment_diagnostics(path, root, scope_index))
134
159
  filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
135
160
  end
136
161
 
@@ -142,17 +167,92 @@ module Rigor
142
167
  nil_receiver_diagnostic(path, node, scope_index),
143
168
  dump_type_diagnostic(path, node, scope_index),
144
169
  assert_type_diagnostic(path, node, scope_index),
145
- always_raises_diagnostic(path, node, scope_index)
170
+ always_raises_diagnostic(path, node, scope_index),
171
+ visibility_mismatch_diagnostic(path, node, scope_index)
146
172
  ].compact
147
173
  end
148
174
 
149
- # v0.0.2 #6 diagnostic suppression. Two kinds of
175
+ # v0.1.2 — `def.ivar-write-mismatch`. Walks every
176
+ # ClassNode / ModuleNode body, gathers per-class ivar
177
+ # writes with their rvalue types, and emits a diagnostic
178
+ # when a later write's concrete class disagrees with the
179
+ # first write's. The first write per (class, ivar) is
180
+ # treated as the "declared" type; subsequent writes that
181
+ # land on a different concrete class trigger.
182
+ #
183
+ # Conservative envelope:
184
+ # - Only fires when both the first and the offending
185
+ # write resolve to a `concrete_class_name` (Nominal /
186
+ # Singleton / Constant / Tuple → "Array" / HashShape →
187
+ # "Hash"). Unions / Dynamic / IntegerRange / shape-
188
+ # varied carriers fall through.
189
+ # - `NilClass` is an intentional widening idiom (`@x =
190
+ # "value"` then later `@x = nil` to "clear") — skipped.
191
+ # - Singleton-method (`def self.foo`) bodies are skipped.
192
+ # Class-level ivars (`@x = 1` outside any def, in the
193
+ # class body) are also skipped — they're a separate
194
+ # surface (`Module#@var`) the engine doesn't yet model.
195
+ def ivar_write_mismatch_diagnostics(path, root, scope_index)
196
+ IvarWriteCollector.new(scope_index).collect(root).flat_map do |class_name, writes_by_ivar|
197
+ writes_by_ivar.flat_map do |ivar_name, writes|
198
+ ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes)
199
+ end
200
+ end
201
+ end
202
+
203
+ # v0.1.2 — `flow.dead-assignment`. Walks every `DefNode`
204
+ # body and emits a diagnostic for each plain
205
+ # `LocalVariableWriteNode` whose target name is never
206
+ # read in the same body. The
207
+ # `Analysis::CheckRules::DeadAssignmentCollector` describes
208
+ # the conservative envelope.
209
+ def dead_assignment_diagnostics(path, root, scope_index)
210
+ DeadAssignmentCollector.new(scope_index).collect(root).map do |result|
211
+ build_dead_assignment_diagnostic(path, result[:write_node], result[:def_node])
212
+ end
213
+ end
214
+
215
+ # v0.1.2 — `flow.always-truthy-condition`. Fires on
216
+ # `if` / `unless` / ternary predicates whose inferred
217
+ # type is a `Type::Constant` AND that don't fall in
218
+ # the literal-only / inside-loop-or-block / defensive-
219
+ # predicate skip envelope (see
220
+ # `Analysis::CheckRules::AlwaysTruthyConditionCollector`
221
+ # for the full triage rationale).
222
+ def always_truthy_condition_diagnostics(path, root, scope_index)
223
+ AlwaysTruthyConditionCollector.new(scope_index).collect(root).map do |result|
224
+ build_always_truthy_condition_diagnostic(path, result.node, result.polarity)
225
+ end
226
+ end
227
+
228
+ def ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes)
229
+ return [] if writes.size < 2
230
+
231
+ first_class = ivar_class_for(writes.first[:type])
232
+ return [] if first_class.nil?
233
+
234
+ writes[1..].filter_map do |write|
235
+ other_class = ivar_class_for(write[:type])
236
+ next nil if other_class.nil? || other_class == "NilClass" || other_class == first_class
237
+
238
+ build_ivar_write_mismatch_diagnostic(path, write[:node], class_name, ivar_name, first_class, other_class)
239
+ end
240
+ end
241
+
242
+ # v0.0.2 #6 — diagnostic suppression. Three kinds of
150
243
  # suppression compose:
151
244
  #
152
245
  # - **Project-level**: `disabled_rules` is the
153
246
  # project's `.rigor.yml` `disable:` list. Any
154
247
  # diagnostic whose `rule` is in the list is dropped.
155
- # - **In-source**: `# rigor:disable <rule1>, <rule2>`
248
+ # - **File-level** (v0.1.2): `# rigor:disable-file <rule1>,
249
+ # <rule2>` anywhere in the file suppresses the matching
250
+ # diagnostic for every line. `# rigor:disable-file all`
251
+ # suppresses every rule across the file. Convention is
252
+ # to put the comment near the top, but Rigor accepts it
253
+ # anywhere — the comment scope is "this file" regardless
254
+ # of position.
255
+ # - **In-source line**: `# rigor:disable <rule1>, <rule2>`
156
256
  # on the same line as the offending expression
157
257
  # suppresses the matching diagnostic for that line
158
258
  # only. `# rigor:disable all` on a line suppresses
@@ -162,34 +262,49 @@ module Rigor
162
262
  # errors, internal analyzer errors) are NEVER
163
263
  # suppressed — they represent failures the user cannot
164
264
  # silence away.
165
- def filter_suppressed(diagnostics, comments:, disabled_rules:)
166
- suppressions = parse_suppression_comments(comments)
265
+ def filter_suppressed(diagnostics, comments:, disabled_rules:) # rubocop:disable Metrics/CyclomaticComplexity
266
+ line_suppressions, file_suppressions = parse_suppression_comments(comments)
167
267
  disabled = expand_rule_tokens(disabled_rules)
168
268
 
169
269
  diagnostics.reject do |diagnostic|
170
270
  rule = diagnostic.rule
171
271
  next false if rule.nil?
172
272
  next true if disabled.include?(rule)
273
+ next true if file_suppressions.include?("all") || file_suppressions.include?(rule)
173
274
 
174
- line_rules = suppressions[diagnostic.line]
275
+ line_rules = line_suppressions[diagnostic.line]
175
276
  line_rules && (line_rules.include?("all") || line_rules.include?(rule))
176
277
  end
177
278
  end
178
279
 
179
- SUPPRESSION_PATTERN = /#\s*rigor:disable\s+(?<rules>[\w.,\s-]+)/
180
- private_constant :SUPPRESSION_PATTERN
280
+ LINE_SUPPRESSION_PATTERN = /#\s*rigor:disable(?!-file)\s+(?<rules>[\w.,\s-]+)/
281
+ private_constant :LINE_SUPPRESSION_PATTERN
181
282
 
283
+ FILE_SUPPRESSION_PATTERN = /#\s*rigor:disable-file\s+(?<rules>[\w.,\s-]+)/
284
+ private_constant :FILE_SUPPRESSION_PATTERN
285
+
286
+ # @return [Array<(Hash{Integer => Set}, Set)>] pair of
287
+ # `(line_suppressions, file_suppressions)`. Line
288
+ # suppressions are keyed by source line number; file
289
+ # suppressions apply to every line.
182
290
  def parse_suppression_comments(comments)
183
- result = Hash.new { |h, k| h[k] = Set.new }
291
+ line_suppressions = Hash.new { |h, k| h[k] = Set.new }
292
+ file_suppressions = Set.new
184
293
  comments.each do |comment|
185
294
  source = comment.location.slice
186
- match = SUPPRESSION_PATTERN.match(source)
187
- next if match.nil?
295
+ if (match = FILE_SUPPRESSION_PATTERN.match(source))
296
+ absorb_suppression_tokens(match[:rules], file_suppressions)
297
+ elsif (match = LINE_SUPPRESSION_PATTERN.match(source))
298
+ absorb_suppression_tokens(match[:rules], line_suppressions[comment.location.start_line])
299
+ end
300
+ end
301
+ [line_suppressions, file_suppressions]
302
+ end
188
303
 
189
- rules = match[:rules].to_s.split(/[\s,]+/).reject(&:empty?)
190
- rules.each { |token| result[comment.location.start_line].merge(expand_token(token)) }
304
+ def absorb_suppression_tokens(raw, target)
305
+ raw.to_s.split(/[\s,]+/).reject(&:empty?).each do |token|
306
+ target.merge(expand_token(token))
191
307
  end
192
- result
193
308
  end
194
309
 
195
310
  # Expands a list of user-supplied rule tokens into the
@@ -698,6 +813,203 @@ module Rigor
698
813
  )
699
814
  end
700
815
 
816
+ # v0.1.2 — `flow.unreachable-branch`. Fires when an
817
+ # `IfNode` / `UnlessNode` whose predicate is a literal
818
+ # `true` / `false` / `nil` (or a literal numeric /
819
+ # string / symbol whose Ruby truthiness is known
820
+ # at-a-glance) has an observable dead branch. The
821
+ # diagnostic points at the dead branch (not the
822
+ # predicate) so the squiggle lands on the code that
823
+ # never runs.
824
+ #
825
+ # Conservative envelope — by deliberate v0.1.2 design:
826
+ # - Only **literal-shaped** predicates fire. Inferred-
827
+ # constant predicates (`x.method?` that happens to
828
+ # fold to `Constant<bool>`) are intentionally
829
+ # skipped — Rigor's loop / mutation / RBS-strictness
830
+ # modelling is incomplete enough that an inferred
831
+ # constant can be a false positive (e.g. accumulator
832
+ # `arr << x` doesn't widen the carrier; defensive
833
+ # `module.name.nil?` checks against anonymous-class
834
+ # nil that the RBS `Module#name -> String` sig hides).
835
+ # The literal-only envelope captures the clear
836
+ # "user wrote `if false`" case without false alarms.
837
+ # - Empty dead branches (e.g. `if false; end` with no
838
+ # body) are skipped — there is no useful location to
839
+ # point at.
840
+ # - Postfix-`if` / `unless` modifiers with a literal
841
+ # predicate ARE flagged (`expr if false` body never
842
+ # runs, exactly like the block form).
843
+ # - Elsif chains (`subsequent` is itself an `IfNode`)
844
+ # ARE flagged — the entire downstream chain is
845
+ # unreachable when the outer predicate is a constant
846
+ # literal.
847
+ #
848
+ # Broadening to inferred-constant predicates is queued
849
+ # for a later v0.1.x release once the loop / mutation
850
+ # gaps named above are closed.
851
+ def unreachable_branch_diagnostic(path, node, scope_index)
852
+ scope = scope_index[node]
853
+ return nil if scope.nil?
854
+
855
+ polarity = literal_predicate_polarity(node.predicate)
856
+ return nil if polarity.nil?
857
+
858
+ dead_branch = unreachable_branch_for(node, polarity == :truthy)
859
+ return nil if dead_branch.nil?
860
+
861
+ build_unreachable_branch_diagnostic(path, dead_branch, polarity)
862
+ end
863
+
864
+ # Returns `:truthy` / `:falsey` for a syntactically-
865
+ # literal predicate, or nil for anything else.
866
+ # `TrueNode`, `FalseNode`, `NilNode` are the
867
+ # unambiguous cases. Numeric / string / symbol literals
868
+ # are always truthy in Ruby (any non-`false` / non-`nil`
869
+ # value is truthy, including `0` and `""`).
870
+ TRUTHY_LITERAL_NODES = [
871
+ Prism::TrueNode, Prism::IntegerNode, Prism::FloatNode,
872
+ Prism::StringNode, Prism::SymbolNode, Prism::RegularExpressionNode
873
+ ].freeze
874
+ private_constant :TRUTHY_LITERAL_NODES
875
+
876
+ FALSEY_LITERAL_NODES = [Prism::FalseNode, Prism::NilNode].freeze
877
+ private_constant :FALSEY_LITERAL_NODES
878
+
879
+ def literal_predicate_polarity(predicate)
880
+ return :truthy if TRUTHY_LITERAL_NODES.any? { |klass| predicate.is_a?(klass) }
881
+ return :falsey if FALSEY_LITERAL_NODES.any? { |klass| predicate.is_a?(klass) }
882
+
883
+ nil
884
+ end
885
+
886
+ # v0.1.2 — `def.method-visibility-mismatch`. Fires when
887
+ # an explicit-receiver `Prism::CallNode` targets a
888
+ # user-class method whose `discovered_method_visibilities`
889
+ # entry is `:private`. The rule is intentionally narrow:
890
+ #
891
+ # - Only `:private`. `:protected` access depends on
892
+ # subclass tracking the engine does not yet model;
893
+ # broadening waits for that surface.
894
+ # - Only user classes whose visibility table the indexer
895
+ # built. RBS-known classes (stdlib, gems) are NOT
896
+ # consulted yet — RBS visibility is reliable but
897
+ # surfacing it would broaden the rule to a level the
898
+ # per-rule false-positive triage hasn't covered.
899
+ # - Implicit-self calls are skipped (always allowed for
900
+ # private). Calls whose receiver is `Prism::SelfNode`
901
+ # are also skipped — Ruby 2.7+ permits `self.foo` for
902
+ # private methods.
903
+ # - Receiver MUST resolve to a `Type::Nominal` so the
904
+ # rule has a single class identity to query. Unions /
905
+ # Dynamic / shape carriers are skipped.
906
+ def visibility_mismatch_diagnostic(path, call_node, scope_index)
907
+ return nil unless explicit_non_self_receiver?(call_node.receiver)
908
+
909
+ scope = scope_index[call_node]
910
+ return nil if scope.nil?
911
+
912
+ receiver_type = scope.type_of(call_node.receiver)
913
+ return nil unless receiver_type.is_a?(Type::Nominal)
914
+
915
+ visibility = scope.discovered_method_visibility(receiver_type.class_name, call_node.name)
916
+ return nil unless visibility == :private
917
+
918
+ build_visibility_mismatch_diagnostic(path, call_node, receiver_type)
919
+ end
920
+
921
+ def explicit_non_self_receiver?(receiver)
922
+ return false if receiver.nil?
923
+ return false if receiver.is_a?(Prism::SelfNode)
924
+
925
+ true
926
+ end
927
+
928
+ def build_visibility_mismatch_diagnostic(path, call_node, receiver_type)
929
+ location = call_node.message_loc || call_node.location
930
+ Diagnostic.new(
931
+ rule: RULE_VISIBILITY_MISMATCH,
932
+ path: path,
933
+ line: location.start_line,
934
+ column: location.start_column + 1,
935
+ message: "private method `#{call_node.name}' called on #{receiver_type.class_name} receiver",
936
+ severity: :error
937
+ )
938
+ end
939
+
940
+ # Pulls a single concrete class name from an ivar write's
941
+ # rvalue type. Returns nil when the type is too unstable
942
+ # to compare (Union / Dynamic / IntegerRange / etc.).
943
+ # `concrete_class_name` already covers Nominal / Singleton
944
+ # / Constant / Tuple / HashShape; the wrapper exists so
945
+ # the ivar rule can extend the envelope (or apply
946
+ # different filters) without disturbing the call rules.
947
+ def ivar_class_for(type)
948
+ concrete_class_name(type)
949
+ end
950
+
951
+ def build_always_truthy_condition_diagnostic(path, predicate_node, polarity)
952
+ location = predicate_node.location
953
+ Diagnostic.new(
954
+ rule: RULE_ALWAYS_TRUTHY_CONDITION,
955
+ path: path,
956
+ line: location.start_line,
957
+ column: location.start_column + 1,
958
+ message: "condition is always #{polarity} (the surrounding flow proves it folds to a constant)",
959
+ severity: :warning
960
+ )
961
+ end
962
+
963
+ def build_dead_assignment_diagnostic(path, write_node, def_node)
964
+ location = write_node.name_loc || write_node.location
965
+ Diagnostic.new(
966
+ rule: RULE_DEAD_ASSIGNMENT,
967
+ path: path,
968
+ line: location.start_line,
969
+ column: location.start_column + 1,
970
+ message: "local `#{write_node.name}' assigned in `#{def_node.name}' but never read",
971
+ severity: :warning
972
+ )
973
+ end
974
+
975
+ # rubocop:disable Metrics/ParameterLists
976
+ def build_ivar_write_mismatch_diagnostic(path, node, class_name, ivar_name, first_class, other_class)
977
+ location = node.name_loc || node.location
978
+ Diagnostic.new(
979
+ rule: RULE_IVAR_WRITE_MISMATCH,
980
+ path: path,
981
+ line: location.start_line,
982
+ column: location.start_column + 1,
983
+ message: "instance variable `#{ivar_name}' on #{class_name} was previously assigned " \
984
+ "#{first_class}; this write assigns #{other_class}",
985
+ severity: :error
986
+ )
987
+ end
988
+ # rubocop:enable Metrics/ParameterLists
989
+
990
+ # Returns the dead-branch node for a literal-predicate
991
+ # if/unless, or nil when no observable branch is dead.
992
+ def unreachable_branch_for(node, truthy)
993
+ dead =
994
+ case node
995
+ when Prism::IfNode then truthy ? node.subsequent : node.statements
996
+ when Prism::UnlessNode then truthy ? node.statements : node.else_clause
997
+ end
998
+ dead unless dead.nil?
999
+ end
1000
+
1001
+ def build_unreachable_branch_diagnostic(path, dead_branch, polarity)
1002
+ location = dead_branch.location
1003
+ Diagnostic.new(
1004
+ rule: RULE_UNREACHABLE_BRANCH,
1005
+ path: path,
1006
+ line: location.start_line,
1007
+ column: location.start_column + 1,
1008
+ message: "unreachable branch: literal predicate is always #{polarity}",
1009
+ severity: :warning
1010
+ )
1011
+ end
1012
+
701
1013
  # v0.0.2 #4 — argument-type-mismatch diagnostic.
702
1014
  # Walks a call's positional arguments and checks each
703
1015
  # against the matching parameter's RBS type via
@@ -915,6 +1227,19 @@ module Rigor
915
1227
  # Method overloads contribute their union of declared
916
1228
  # return types (any one of them satisfying the body
917
1229
  # silences the rule).
1230
+ #
1231
+ # v0.1.2 — when the RBS sig carries a
1232
+ # `%a{rigor:v1:return: <refinement>}` annotation
1233
+ # (recognised by `RbsExtended.read_return_type_override`),
1234
+ # the refinement carrier replaces the RBS-declared
1235
+ # return for this rule. Annotation-driven refinements
1236
+ # — `non-empty-string`, `positive-int`, `non-empty-
1237
+ # array[Integer]`, etc. — are stricter than the
1238
+ # underlying RBS class, so a body whose inferred type
1239
+ # the bare RBS sig would accept may still fail the
1240
+ # refinement (e.g. `def name; ""; end` returns
1241
+ # `Constant[""]`, accepted by `String` but rejected by
1242
+ # `non-empty-string`).
918
1243
  def declared_return_type(def_node, scope_index)
919
1244
  scope = scope_index[def_node]
920
1245
  return nil if scope.nil?
@@ -930,6 +1255,9 @@ module Rigor
930
1255
  end
931
1256
  return nil if method_def.nil?
932
1257
 
1258
+ override = Rigor::RbsExtended.read_return_type_override(method_def)
1259
+ return override if override
1260
+
933
1261
  declared_return_union(method_def, scope.environment)
934
1262
  end
935
1263
 
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "gem_resolver"
4
+ require_relative "index"
5
+ require_relative "walker"
6
+
7
+ module Rigor
8
+ module Analysis
9
+ module DependencySourceInference
10
+ # Folds a `Configuration::Dependencies` value into a
11
+ # frozen {Index}. Resolves each non-disabled entry through
12
+ # {GemResolver}, walks each resolved gem's `roots:` via
13
+ # {Walker.walk} under the configured `budget_per_gem` cap,
14
+ # and aggregates the per-gem method catalogs into the
15
+ # Index's flat `(class_name, method_name) → kind` table.
16
+ #
17
+ # Entries with `mode: :disabled` are skipped without
18
+ # resolution attempts so users can "list and disable" a
19
+ # gem in configuration without provoking a missing-gem
20
+ # diagnostic.
21
+ module Builder
22
+ module_function
23
+
24
+ # @param dependencies [Rigor::Configuration::Dependencies]
25
+ # @return [Index]
26
+ def build(dependencies) # rubocop:disable Metrics/MethodLength
27
+ return Index::EMPTY if dependencies.empty?
28
+
29
+ resolved = []
30
+ unresolvable = []
31
+ catalog = {}
32
+ class_to_gem = {}
33
+ budget_exceeded = []
34
+ budget = dependencies.budget_per_gem
35
+
36
+ dependencies.source_inference.each do |entry|
37
+ next if entry.disabled?
38
+
39
+ outcome = GemResolver.resolve(entry)
40
+ case outcome
41
+ when GemResolver::Resolved
42
+ resolved << outcome
43
+ walked = walker_outcome_for(outcome, budget)
44
+ catalog.merge!(walked.catalog)
45
+ record_class_to_gem(walked.catalog, outcome.gem_name, class_to_gem)
46
+ budget_exceeded << outcome.gem_name if walked.truncated?
47
+ when GemResolver::Unresolvable then unresolvable << outcome
48
+ end
49
+ end
50
+
51
+ Index.new(
52
+ resolved_gems: resolved, unresolvable: unresolvable,
53
+ method_catalog: catalog, budget_exceeded: budget_exceeded,
54
+ class_to_gem: class_to_gem,
55
+ budget_overrun_strategy: dependencies.budget_overrun_strategy
56
+ )
57
+ end
58
+
59
+ # ADR-10 5b — per-class reverse-lookup table (β budget
60
+ # semantics). Records `class_name → gem_name` for every
61
+ # class observed in the gem's catalog. First-write-wins:
62
+ # if two opt-in gems re-open the same class, the first
63
+ # gem to harvest the class owns it in the reverse index.
64
+ # The dispatcher only consults this map when the
65
+ # `budget_overrun_strategy` is `:dependency_silence`,
66
+ # so the storage cost is never paid back unless the
67
+ # user opts in.
68
+ def record_class_to_gem(catalog, gem_name, class_to_gem)
69
+ catalog.each_key do |(class_name, _method_name)|
70
+ class_to_gem[class_name] ||= gem_name
71
+ end
72
+ end
73
+
74
+ # Per-resolved-gem walk. Isolated so a single gem's
75
+ # filesystem error / parse failure cannot abort the
76
+ # build; the walker swallows its own per-file errors,
77
+ # and a top-level raise here degrades the gem to "no
78
+ # contributions" without touching the rest of the run.
79
+ def walker_outcome_for(resolved, budget)
80
+ Walker.walk(gem_dir: resolved.gem_dir, roots: resolved.roots, budget: budget)
81
+ rescue StandardError
82
+ Walker::Outcome.new(catalog: {}.freeze, truncated: false)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ module DependencySourceInference
6
+ # Maps a `Configuration::Dependencies::Entry` to the gem's
7
+ # on-disk installation directory by consulting RubyGems
8
+ # (`Gem.loaded_specs` first, falling back to
9
+ # `Gem::Specification.find_by_name`). Returns either a
10
+ # frozen {Resolved} value object or an {Unresolvable} value
11
+ # describing why the gem cannot participate in this run.
12
+ #
13
+ # Resolution failures are surfaced as
14
+ # `dynamic.dependency-source.gem-not-found` diagnostics by
15
+ # {Analysis::Runner} rather than crashing the run, so a
16
+ # missing gem in `dependencies.source_inference` degrades
17
+ # cleanly to "no contributions from that gem" — every other
18
+ # gem and the project source remain unaffected.
19
+ module GemResolver
20
+ # Successful resolution. `version` is the spec version as
21
+ # a String so it round-trips into cache descriptors
22
+ # (slice 3) without leaking a `Gem::Version` instance
23
+ # through public surfaces.
24
+ Resolved = Data.define(:gem_name, :version, :gem_dir, :mode, :roots) do
25
+ def descriptor_key
26
+ [gem_name, version, mode].freeze
27
+ end
28
+ end
29
+
30
+ # Unresolvable reasons. `:not_in_bundle` covers both the
31
+ # "RubyGems doesn't know this gem" case and the
32
+ # `LoadError`-style raise from `find_by_name`. Future
33
+ # reasons (`:c_extension_only`, `:no_lib_root`) are
34
+ # introduced as the walker discovers them in slice 2b.
35
+ Unresolvable = Data.define(:gem_name, :reason)
36
+
37
+ VALID_REASONS = %i[not_in_bundle].freeze
38
+
39
+ module_function
40
+
41
+ # @param entry [Rigor::Configuration::Dependencies::Entry]
42
+ # @return [Resolved, Unresolvable]
43
+ def resolve(entry)
44
+ spec = locate_gem_spec(entry.gem)
45
+ return Unresolvable.new(gem_name: entry.gem, reason: :not_in_bundle) if spec.nil?
46
+
47
+ Resolved.new(
48
+ gem_name: entry.gem,
49
+ version: spec.version.to_s,
50
+ gem_dir: spec.full_gem_path, # rigor:disable undefined-method
51
+ mode: entry.mode,
52
+ roots: entry.roots
53
+ )
54
+ end
55
+
56
+ # Locator. `Gem.loaded_specs` reflects the bundle (cheap
57
+ # lookup, no filesystem walk); `find_by_name` is the
58
+ # broader fallback for gems present on the gem path but
59
+ # not yet `require`'d. `Gem::MissingSpecError` is a
60
+ # `LoadError` subclass, so the rescue covers both
61
+ # missing-spec and load-error signals.
62
+ def locate_gem_spec(name)
63
+ Gem.loaded_specs[name] || begin
64
+ Gem::Specification.find_by_name(name)
65
+ rescue LoadError
66
+ nil
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end