rigortype 0.1.16 → 0.1.17

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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
  3. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +209 -0
  4. data/lib/rigor/analysis/check_rules.rb +149 -70
  5. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  6. data/lib/rigor/analysis/diagnostic.rb +18 -0
  7. data/lib/rigor/analysis/incremental.rb +162 -0
  8. data/lib/rigor/analysis/incremental_session.rb +337 -0
  9. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  10. data/lib/rigor/analysis/runner.rb +434 -37
  11. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  12. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  13. data/lib/rigor/cache/descriptor.rb +50 -49
  14. data/lib/rigor/cache/incremental_snapshot.rb +147 -0
  15. data/lib/rigor/cache/rbs_cache_producer.rb +30 -0
  16. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  17. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  18. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  19. data/lib/rigor/cache/rbs_environment.rb +2 -8
  20. data/lib/rigor/cache/rbs_instance_definitions.rb +3 -16
  21. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  22. data/lib/rigor/cache/store.rb +99 -1
  23. data/lib/rigor/cli/annotate_command.rb +2 -7
  24. data/lib/rigor/cli/baseline_command.rb +2 -7
  25. data/lib/rigor/cli/command.rb +47 -0
  26. data/lib/rigor/cli/coverage_command.rb +3 -23
  27. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  28. data/lib/rigor/cli/diff_command.rb +3 -7
  29. data/lib/rigor/cli/explain_command.rb +2 -7
  30. data/lib/rigor/cli/lsp_command.rb +3 -7
  31. data/lib/rigor/cli/mcp_command.rb +3 -7
  32. data/lib/rigor/cli/options.rb +57 -0
  33. data/lib/rigor/cli/plugin_command.rb +3 -7
  34. data/lib/rigor/cli/plugins_command.rb +2 -7
  35. data/lib/rigor/cli/renderable.rb +26 -0
  36. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  37. data/lib/rigor/cli/skill_command.rb +3 -7
  38. data/lib/rigor/cli/triage_command.rb +2 -7
  39. data/lib/rigor/cli/type_of_command.rb +5 -38
  40. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  41. data/lib/rigor/cli/type_scan_command.rb +3 -23
  42. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  43. data/lib/rigor/cli.rb +125 -43
  44. data/lib/rigor/configuration/dependencies.rb +18 -1
  45. data/lib/rigor/configuration/severity_profile.rb +22 -3
  46. data/lib/rigor/configuration.rb +13 -3
  47. data/lib/rigor/environment/rbs_loader.rb +76 -3
  48. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  49. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  50. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  51. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  52. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  53. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  54. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  55. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  56. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  57. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  58. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  59. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  60. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  61. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  62. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  63. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  64. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  65. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  69. data/lib/rigor/inference/expression_typer.rb +140 -20
  70. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  71. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  72. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  73. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  74. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  75. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  76. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  77. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  78. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  79. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  80. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  81. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
  82. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  83. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  84. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  85. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  86. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  87. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  88. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  89. data/lib/rigor/inference/method_dispatcher.rb +99 -59
  90. data/lib/rigor/inference/narrowing.rb +202 -5
  91. data/lib/rigor/inference/scope_indexer.rb +134 -7
  92. data/lib/rigor/inference/statement_evaluator.rb +105 -26
  93. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  94. data/lib/rigor/language_server/completion_provider.rb +4 -4
  95. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  96. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  97. data/lib/rigor/language_server/hover_provider.rb +4 -4
  98. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  99. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  100. data/lib/rigor/plugin/base.rb +20 -4
  101. data/lib/rigor/plugin/registry.rb +39 -1
  102. data/lib/rigor/rbs_extended/conformance_checker.rb +208 -0
  103. data/lib/rigor/rbs_extended.rb +39 -0
  104. data/lib/rigor/scope.rb +123 -9
  105. data/lib/rigor/type/acceptance_router.rb +19 -0
  106. data/lib/rigor/type/accepts_result.rb +3 -10
  107. data/lib/rigor/type/app.rb +3 -7
  108. data/lib/rigor/type/bot.rb +2 -3
  109. data/lib/rigor/type/bound_method.rb +5 -12
  110. data/lib/rigor/type/combinator.rb +17 -0
  111. data/lib/rigor/type/constant.rb +2 -3
  112. data/lib/rigor/type/data_class.rb +80 -0
  113. data/lib/rigor/type/data_instance.rb +100 -0
  114. data/lib/rigor/type/difference.rb +5 -10
  115. data/lib/rigor/type/dynamic.rb +5 -10
  116. data/lib/rigor/type/hash_shape.rb +5 -15
  117. data/lib/rigor/type/integer_range.rb +5 -10
  118. data/lib/rigor/type/intersection.rb +5 -10
  119. data/lib/rigor/type/nominal.rb +5 -10
  120. data/lib/rigor/type/refined.rb +5 -10
  121. data/lib/rigor/type/singleton.rb +5 -10
  122. data/lib/rigor/type/top.rb +2 -3
  123. data/lib/rigor/type/tuple.rb +5 -10
  124. data/lib/rigor/type/union.rb +5 -10
  125. data/lib/rigor/type.rb +2 -0
  126. data/lib/rigor/value_semantics.rb +77 -0
  127. data/lib/rigor/version.rb +1 -1
  128. data/lib/rigor.rb +1 -0
  129. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
  130. data/sig/rigor/cache.rbs +19 -0
  131. data/sig/rigor/inference.rbs +22 -0
  132. data/sig/rigor/rbs_extended.rbs +2 -0
  133. data/sig/rigor/scope.rbs +5 -0
  134. data/sig/rigor/type.rbs +58 -1
  135. data/sig/rigor.rbs +6 -1
  136. metadata +22 -1
@@ -6,9 +6,12 @@ require_relative "../reflection"
6
6
  require_relative "../source/node_walker"
7
7
  require_relative "../type"
8
8
  require_relative "diagnostic"
9
+ require_relative "dependency_recorder"
9
10
  require_relative "check_rules/always_truthy_condition_collector"
11
+ require_relative "check_rules/unreachable_clause_collector"
10
12
  require_relative "check_rules/dead_assignment_collector"
11
13
  require_relative "check_rules/ivar_write_collector"
14
+ require_relative "check_rules/self_closedness_scanner"
12
15
 
13
16
  module Rigor
14
17
  module Analysis
@@ -57,6 +60,7 @@ module Rigor
57
60
  # system; new rules MUST register here so user configuration
58
61
  # can refer to them.
59
62
  RULE_UNDEFINED_METHOD = "call.undefined-method"
63
+ RULE_SELF_UNDEFINED_METHOD = "call.self-undefined-method"
60
64
  RULE_UNRESOLVED_TOPLEVEL = "call.unresolved-toplevel"
61
65
  RULE_WRONG_ARITY = "call.wrong-arity"
62
66
  RULE_ARGUMENT_TYPE = "call.argument-type-mismatch"
@@ -73,9 +77,11 @@ module Rigor
73
77
  RULE_IVAR_WRITE_MISMATCH = "def.ivar-write-mismatch"
74
78
  RULE_DEAD_ASSIGNMENT = "flow.dead-assignment"
75
79
  RULE_ALWAYS_TRUTHY_CONDITION = "flow.always-truthy-condition"
80
+ RULE_UNREACHABLE_CLAUSE = "flow.unreachable-clause"
76
81
 
77
82
  ALL_RULES = [
78
83
  RULE_UNDEFINED_METHOD,
84
+ RULE_SELF_UNDEFINED_METHOD,
79
85
  RULE_UNRESOLVED_TOPLEVEL,
80
86
  RULE_WRONG_ARITY,
81
87
  RULE_ARGUMENT_TYPE,
@@ -86,6 +92,7 @@ module Rigor
86
92
  RULE_UNREACHABLE_BRANCH,
87
93
  RULE_DEAD_ASSIGNMENT,
88
94
  RULE_ALWAYS_TRUTHY_CONDITION,
95
+ RULE_UNREACHABLE_CLAUSE,
89
96
  RULE_RETURN_TYPE,
90
97
  RULE_VISIBILITY_MISMATCH,
91
98
  RULE_OVERRIDE_VISIBILITY_REDUCED,
@@ -104,6 +111,7 @@ module Rigor
104
111
  # both spellings resolve identically.
105
112
  LEGACY_RULE_ALIASES = {
106
113
  "undefined-method" => RULE_UNDEFINED_METHOD,
114
+ "self-undefined-method" => RULE_SELF_UNDEFINED_METHOD,
107
115
  "wrong-arity" => RULE_WRONG_ARITY,
108
116
  "argument-type-mismatch" => RULE_ARGUMENT_TYPE,
109
117
  "possible-nil-receiver" => RULE_NIL_RECEIVER,
@@ -114,7 +122,8 @@ module Rigor
114
122
  "method-visibility-mismatch" => RULE_VISIBILITY_MISMATCH,
115
123
  "ivar-write-mismatch" => RULE_IVAR_WRITE_MISMATCH,
116
124
  "dead-assignment" => RULE_DEAD_ASSIGNMENT,
117
- "always-truthy-condition" => RULE_ALWAYS_TRUTHY_CONDITION
125
+ "always-truthy-condition" => RULE_ALWAYS_TRUTHY_CONDITION,
126
+ "unreachable-clause" => RULE_UNREACHABLE_CLAUSE
118
127
  }.freeze
119
128
 
120
129
  # Family wildcard — a `<family>` token in a suppression
@@ -153,7 +162,7 @@ module Rigor
153
162
  # @param root [Prism::Node]
154
163
  # @param scope_index [Hash{Prism::Node => Rigor::Scope}]
155
164
  # @return [Array<Rigor::Analysis::Diagnostic>]
156
- def diagnose(path:, root:, scope_index:, comments: [], disabled_rules: [])
165
+ def diagnose(path:, root:, scope_index:, self_call_misses: [], comments: [], disabled_rules: [])
157
166
  diagnostics = []
158
167
  Source::NodeWalker.each(root) do |node|
159
168
  case node
@@ -173,7 +182,9 @@ module Rigor
173
182
  diagnostics << unreachable if unreachable
174
183
  end
175
184
  end
185
+ diagnostics.concat(self_undefined_method_diagnostics(path, self_call_misses, root, scope_index))
176
186
  diagnostics.concat(always_truthy_condition_diagnostics(path, root, scope_index))
187
+ diagnostics.concat(unreachable_clause_diagnostics(path, root, scope_index))
177
188
  diagnostics.concat(ivar_write_mismatch_diagnostics(path, root, scope_index))
178
189
  diagnostics.concat(dead_assignment_diagnostics(path, root, scope_index))
179
190
  filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
@@ -246,6 +257,16 @@ module Rigor
246
257
  end
247
258
  end
248
259
 
260
+ # ADR-47 — `flow.unreachable-clause`. One diagnostic per `when` clause
261
+ # the flow engine's narrowing proves can never match (its narrowed
262
+ # subject is `bot`). The squiggle lands on the dead clause's body,
263
+ # mirroring `flow.unreachable-branch`.
264
+ def unreachable_clause_diagnostics(path, root, scope_index)
265
+ UnreachableClauseCollector.new(scope_index).collect(root).map do |result|
266
+ build_unreachable_clause_diagnostic(path, result)
267
+ end
268
+ end
269
+
249
270
  def ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes)
250
271
  return [] if writes.size < 2
251
272
 
@@ -527,11 +548,9 @@ module Rigor
527
548
  end
528
549
 
529
550
  def build_unresolved_toplevel_diagnostic(path, call_node)
530
- location = call_node.message_loc || call_node.location
531
- Diagnostic.new(
551
+ Diagnostic.from_message_loc(
552
+ call_node,
532
553
  path: path,
533
- line: location.start_line,
534
- column: location.start_column + 1,
535
554
  message: "unresolved toplevel call to `#{call_node.name}`. " \
536
555
  "If a project file defines `#{call_node.name}` via a toplevel " \
537
556
  "`def` or a monkey-patch on Object/Kernel, list that file in " \
@@ -605,6 +624,65 @@ module Rigor
605
624
  end
606
625
  end
607
626
 
627
+ # ADR-24 slice 4 — `call.self-undefined-method`. Consumes the engine's
628
+ # recorded unresolved implicit-self calls
629
+ # ({Analysis::SelfCallResolutionRecorder}) and adds only the
630
+ # closedness POLICY — it NEVER recomputes resolution (the reverted
631
+ # attempt-1 mistake that produced 135 FPs). A miss reaches here only
632
+ # because the engine's real resolution found the method nowhere.
633
+ #
634
+ # The v1 gate is deliberately the most conservative "confidently
635
+ # closed" shape: a STANDALONE project class — no superclass and no
636
+ # `include`/`prepend` (so its in-file method surface is complete) —
637
+ # that is not a module / mixin contract, defines no `method_missing`,
638
+ # has no dynamic `attr_*(*splat)` accessor, and is not an ADR-26 open
639
+ # receiver. Widening to superclass / include chains is a later slice,
640
+ # each behind the external corpus FP gate. Authored `:warning` but
641
+ # mapped to `:off` in every shipped profile until that gate is green
642
+ # (ADR-24 § "Slice 4"); a project opts in via `severity_overrides:`.
643
+ def self_undefined_method_diagnostics(path, self_call_misses, root, scope_index)
644
+ return [] if self_call_misses.empty?
645
+
646
+ open_names = SelfClosednessScanner.new(root).open_class_names
647
+ self_call_misses.filter_map do |miss|
648
+ next if open_names.include?(miss.class_name)
649
+
650
+ scope = scope_index[miss.node]
651
+ next if scope.nil?
652
+ next unless confidently_closed_self_class?(miss.class_name, scope)
653
+
654
+ build_self_undefined_method_diagnostic(path, miss)
655
+ end
656
+ end
657
+
658
+ def confidently_closed_self_class?(class_name, scope)
659
+ return false if unbounded_receiver_surface?(class_name, scope)
660
+ return false if scope.discovered_method?(class_name, :method_missing, :instance)
661
+ # A superclass or mixin extends the surface beyond what this file
662
+ # declares; the engine's ancestor walk may have hit an unresolvable
663
+ # ancestor, so a miss is not provably a typo. Defer to a later slice.
664
+ return false if scope.superclass_of(class_name)
665
+ return false unless scope.includes_of(class_name).empty?
666
+
667
+ true
668
+ end
669
+
670
+ def build_self_undefined_method_diagnostic(path, miss)
671
+ Diagnostic.new(
672
+ path: path,
673
+ line: miss.line || 1,
674
+ column: miss.column || 1,
675
+ message: "implicit-self call to `#{miss.method_name}` resolves to no method on " \
676
+ "`#{miss.class_name}` (a standalone class with a complete, project-known " \
677
+ "method surface). Likely a typo or a missing `def`.",
678
+ severity: :warning,
679
+ rule: RULE_SELF_UNDEFINED_METHOD,
680
+ source_family: :builtin,
681
+ receiver_type: miss.class_name,
682
+ method_name: miss.method_name
683
+ )
684
+ end
685
+
608
686
  def lookup_method(receiver_type, class_name, method_name, scope)
609
687
  if receiver_type.is_a?(Type::Singleton)
610
688
  Rigor::Reflection.singleton_method_definition(class_name, method_name, scope: scope)
@@ -832,11 +910,9 @@ module Rigor
832
910
  return nil if inside_rigor_testing?(scope)
833
911
 
834
912
  type = scope.type_of(arg)
835
- location = call_node.message_loc || call_node.location
836
- Diagnostic.new(
913
+ Diagnostic.from_message_loc(
914
+ call_node,
837
915
  path: path,
838
- line: location.start_line,
839
- column: location.start_column + 1,
840
916
  message: "dump_type: #{type.describe(:short)}",
841
917
  severity: :info,
842
918
  rule: RULE_DUMP_TYPE
@@ -929,24 +1005,20 @@ module Rigor
929
1005
  end
930
1006
 
931
1007
  def build_assert_type_diagnostic(path, call_node, expected, actual)
932
- location = call_node.message_loc || call_node.location
933
- Diagnostic.new(
1008
+ Diagnostic.from_message_loc(
1009
+ call_node,
934
1010
  rule: RULE_ASSERT_TYPE,
935
1011
  path: path,
936
- line: location.start_line,
937
- column: location.start_column + 1,
938
1012
  message: "assert_type mismatch: expected #{expected.inspect}, got #{actual.inspect}",
939
1013
  severity: :error
940
1014
  )
941
1015
  end
942
1016
 
943
1017
  def build_nil_receiver_diagnostic(path, call_node)
944
- location = call_node.message_loc || call_node.location
945
- Diagnostic.new(
1018
+ Diagnostic.from_message_loc(
1019
+ call_node,
946
1020
  rule: RULE_NIL_RECEIVER,
947
1021
  path: path,
948
- line: location.start_line,
949
- column: location.start_column + 1,
950
1022
  message: "possible nil receiver: `#{call_node.name}' is undefined on NilClass",
951
1023
  severity: :error
952
1024
  )
@@ -1009,12 +1081,10 @@ module Rigor
1009
1081
  end
1010
1082
 
1011
1083
  def build_always_raises_diagnostic(path, call_node)
1012
- location = call_node.message_loc || call_node.location
1013
- Diagnostic.new(
1084
+ Diagnostic.from_message_loc(
1085
+ call_node,
1014
1086
  rule: RULE_ALWAYS_RAISES,
1015
1087
  path: path,
1016
- line: location.start_line,
1017
- column: location.start_column + 1,
1018
1088
  message: "always raises ZeroDivisionError: `#{call_node.name}' by zero on Integer receiver",
1019
1089
  severity: :error
1020
1090
  )
@@ -1133,12 +1203,10 @@ module Rigor
1133
1203
  end
1134
1204
 
1135
1205
  def build_visibility_mismatch_diagnostic(path, call_node, receiver_type)
1136
- location = call_node.message_loc || call_node.location
1137
- Diagnostic.new(
1206
+ Diagnostic.from_message_loc(
1207
+ call_node,
1138
1208
  rule: RULE_VISIBILITY_MISMATCH,
1139
1209
  path: path,
1140
- line: location.start_line,
1141
- column: location.start_column + 1,
1142
1210
  message: "private method `#{call_node.name}' called on #{receiver_type.class_name} receiver",
1143
1211
  severity: :error
1144
1212
  )
@@ -1166,36 +1234,55 @@ module Rigor
1166
1234
  end
1167
1235
 
1168
1236
  def build_always_truthy_condition_diagnostic(path, predicate_node, polarity)
1169
- location = predicate_node.location
1170
- Diagnostic.new(
1237
+ Diagnostic.from_node(
1238
+ predicate_node,
1171
1239
  rule: RULE_ALWAYS_TRUTHY_CONDITION,
1172
1240
  path: path,
1173
- line: location.start_line,
1174
- column: location.start_column + 1,
1175
1241
  message: "condition is always #{polarity} (the surrounding flow proves it folds to a constant)",
1176
1242
  severity: :warning
1177
1243
  )
1178
1244
  end
1179
1245
 
1246
+ def build_unreachable_clause_diagnostic(path, result)
1247
+ Diagnostic.from_node(
1248
+ result.body,
1249
+ rule: RULE_UNREACHABLE_CLAUSE,
1250
+ path: path,
1251
+ message: unreachable_clause_message(result),
1252
+ severity: :warning
1253
+ )
1254
+ end
1255
+
1256
+ def unreachable_clause_message(result)
1257
+ subject = result.subject_name
1258
+ kw = result.keyword
1259
+ case result.kind
1260
+ when :prior_exhaustion
1261
+ "unreachable `#{kw} #{result.condition_source}': `#{subject}' is already covered " \
1262
+ "by an earlier `#{kw}' clause"
1263
+ when :exhausted_else
1264
+ "unreachable `else': the `#{kw}' clauses already cover every value `#{subject}' can take here"
1265
+ else # :disjoint
1266
+ "unreachable `#{kw} #{result.condition_source}': `#{subject}' can never be " \
1267
+ "#{result.condition_source} here (the flow proves the subject disjoint)"
1268
+ end
1269
+ end
1270
+
1180
1271
  def build_dead_assignment_diagnostic(path, write_node, def_node)
1181
- location = write_node.name_loc || write_node.location
1182
- Diagnostic.new(
1272
+ Diagnostic.from_name_loc(
1273
+ write_node,
1183
1274
  rule: RULE_DEAD_ASSIGNMENT,
1184
1275
  path: path,
1185
- line: location.start_line,
1186
- column: location.start_column + 1,
1187
1276
  message: "local `#{write_node.name}' assigned in `#{def_node.name}' but never read",
1188
1277
  severity: :warning
1189
1278
  )
1190
1279
  end
1191
1280
 
1192
1281
  def build_ivar_write_mismatch_diagnostic(path, node, class_name, ivar_name, first_class, other_class)
1193
- location = node.name_loc || node.location
1194
- Diagnostic.new(
1282
+ Diagnostic.from_name_loc(
1283
+ node,
1195
1284
  rule: RULE_IVAR_WRITE_MISMATCH,
1196
1285
  path: path,
1197
- line: location.start_line,
1198
- column: location.start_column + 1,
1199
1286
  message: "instance variable `#{ivar_name}' on #{class_name} was previously assigned " \
1200
1287
  "#{first_class}; this write assigns #{other_class}",
1201
1288
  severity: :error
@@ -1214,12 +1301,10 @@ module Rigor
1214
1301
  end
1215
1302
 
1216
1303
  def build_unreachable_branch_diagnostic(path, dead_branch, polarity)
1217
- location = dead_branch.location
1218
- Diagnostic.new(
1304
+ Diagnostic.from_node(
1305
+ dead_branch,
1219
1306
  rule: RULE_UNREACHABLE_BRANCH,
1220
1307
  path: path,
1221
- line: location.start_line,
1222
- column: location.start_column + 1,
1223
1308
  message: "unreachable branch: literal predicate is always #{polarity}",
1224
1309
  severity: :warning
1225
1310
  )
@@ -1348,39 +1433,34 @@ module Rigor
1348
1433
  end
1349
1434
 
1350
1435
  def build_argument_type_diagnostic(path, call_node, class_name, mismatch)
1351
- location = mismatch[:node].location
1352
1436
  method_label = "`#{call_node.name}' on #{class_name}"
1353
1437
  parameter_label = mismatch[:name] ? "parameter `#{mismatch[:name]}' of #{method_label}" : method_label
1354
1438
  message = "argument type mismatch at #{parameter_label}: " \
1355
1439
  "expected #{mismatch[:expected].describe(:short)}, " \
1356
1440
  "got #{mismatch[:actual].describe(:short)}"
1357
- Diagnostic.new(
1441
+ Diagnostic.from_node(
1442
+ mismatch[:node],
1358
1443
  rule: RULE_ARGUMENT_TYPE,
1359
1444
  path: path,
1360
- line: location.start_line,
1361
- column: location.start_column + 1,
1362
1445
  message: message,
1363
1446
  severity: :error
1364
1447
  )
1365
1448
  end
1366
1449
 
1367
1450
  def build_arity_diagnostic(path, call_node, class_name, min, max, actual)
1368
- location = call_node.message_loc || call_node.location
1369
1451
  range = min == max ? min.to_s : "#{min}..#{max}"
1370
1452
  method_label = "`#{call_node.name}' on #{class_name}"
1371
1453
  message = "wrong number of arguments to #{method_label} (given #{actual}, expected #{range})"
1372
- Diagnostic.new(
1454
+ Diagnostic.from_message_loc(
1455
+ call_node,
1373
1456
  rule: RULE_WRONG_ARITY,
1374
1457
  path: path,
1375
- line: location.start_line,
1376
- column: location.start_column + 1,
1377
1458
  message: message,
1378
1459
  severity: :error
1379
1460
  )
1380
1461
  end
1381
1462
 
1382
1463
  def build_undefined_method_diagnostic(path, call_node, receiver_type, definition_site = nil, class_name = nil)
1383
- location = call_node.message_loc || call_node.location
1384
1464
  rendered_receiver = receiver_type.describe
1385
1465
  message = "undefined method `#{call_node.name}' for #{rendered_receiver}"
1386
1466
  # ADR-17 — when the project itself defines this method on the
@@ -1396,11 +1476,10 @@ module Rigor
1396
1476
  "#{definition_site} — Rigor does not apply project monkey-patches " \
1397
1477
  "cross-file; list that file in `.rigor.yml`'s `pre_eval:` (ADR-17)"
1398
1478
  end
1399
- Diagnostic.new(
1479
+ Diagnostic.from_message_loc(
1480
+ call_node,
1400
1481
  rule: RULE_UNDEFINED_METHOD,
1401
1482
  path: path,
1402
- line: location.start_line,
1403
- column: location.start_column + 1,
1404
1483
  message: message,
1405
1484
  severity: :error,
1406
1485
  receiver_type: rendered_receiver,
@@ -1544,12 +1623,10 @@ module Rigor
1544
1623
  end
1545
1624
 
1546
1625
  def build_return_type_mismatch_diagnostic(path, def_node, declared, inferred, severity)
1547
- location = def_node.name_loc || def_node.location
1548
- Diagnostic.new(
1626
+ Diagnostic.from_name_loc(
1627
+ def_node,
1549
1628
  rule: RULE_RETURN_TYPE,
1550
1629
  path: path,
1551
- line: location.start_line,
1552
- column: location.start_column + 1,
1553
1630
  message: "return-type mismatch on `#{def_node.name}': " \
1554
1631
  "declared #{declared.describe(:short)}, inferred #{inferred.describe(:short)}",
1555
1632
  severity: severity
@@ -1679,6 +1756,14 @@ module Rigor
1679
1756
  candidate = (segments[0, i] + [raw_ancestor]).join("::")
1680
1757
  return candidate if known_user_class?(scope, candidate)
1681
1758
  end
1759
+ # ADR-46 slice 3 — the override checker reads the class graph
1760
+ # directly (not through the recorder's `Scope` choke points), and
1761
+ # short-circuits when the ancestor resolves to no project class, so
1762
+ # an incremental re-check has no edge telling it to re-check this
1763
+ # subclass when that ancestor is later defined. Record a negative
1764
+ # class edge (keyed on the unqualified name) so the appeared-class
1765
+ # widening picks it up.
1766
+ DependencyRecorder.read_missing(:class, raw_ancestor.to_s.split("::").last) if DependencyRecorder.active?
1682
1767
  nil
1683
1768
  end
1684
1769
 
@@ -1689,12 +1774,10 @@ module Rigor
1689
1774
  end
1690
1775
 
1691
1776
  def build_override_visibility_diagnostic(path, def_node, parent_class, parent_visibility, override_visibility)
1692
- location = def_node.name_loc || def_node.location
1693
- Diagnostic.new(
1777
+ Diagnostic.from_name_loc(
1778
+ def_node,
1694
1779
  rule: RULE_OVERRIDE_VISIBILITY_REDUCED,
1695
1780
  path: path,
1696
- line: location.start_line,
1697
- column: location.start_column + 1,
1698
1781
  message: "visibility of `#{def_node.name}' reduced from #{parent_visibility} to " \
1699
1782
  "#{override_visibility} (overrides #{parent_class}##{def_node.name}); " \
1700
1783
  "breaks substitutability",
@@ -1784,12 +1867,10 @@ module Rigor
1784
1867
  end
1785
1868
 
1786
1869
  def build_override_return_widened_diagnostic(path, def_node, parent_class, parent_return, override_return)
1787
- location = def_node.name_loc || def_node.location
1788
- Diagnostic.new(
1870
+ Diagnostic.from_name_loc(
1871
+ def_node,
1789
1872
  rule: RULE_OVERRIDE_RETURN_WIDENED,
1790
1873
  path: path,
1791
- line: location.start_line,
1792
- column: location.start_column + 1,
1793
1874
  message: "return type of `#{def_node.name}' widened from #{parent_return.describe(:short)} " \
1794
1875
  "to #{override_return.describe(:short)} (overrides #{parent_class}##{def_node.name}); " \
1795
1876
  "breaks substitutability",
@@ -1890,12 +1971,10 @@ module Rigor
1890
1971
  end
1891
1972
 
1892
1973
  def build_override_param_narrowed_diagnostic(path, def_node, parent_class, index, parent_param, override_param)
1893
- location = def_node.name_loc || def_node.location
1894
- Diagnostic.new(
1974
+ Diagnostic.from_name_loc(
1975
+ def_node,
1895
1976
  rule: RULE_OVERRIDE_PARAM_NARROWED,
1896
1977
  path: path,
1897
- line: location.start_line,
1898
- column: location.start_column + 1,
1899
1978
  message: "parameter #{index + 1} of `#{def_node.name}' narrowed from " \
1900
1979
  "#{parent_param.describe(:short)} to #{override_param.describe(:short)} " \
1901
1980
  "(overrides #{parent_class}##{def_node.name}); breaks substitutability",
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ # ADR-46 slice 1 — records, per analyzed file, which OTHER source
6
+ # files its analysis read declarations / method bodies from (the
7
+ # cross-file dependency edges), plus the cross-file lookups that
8
+ # resolved to nothing (negative edges — adding that symbol later must
9
+ # re-check the consumer).
10
+ #
11
+ # Thread-local and activated per `analyze_file` only when the runner
12
+ # opts in (`record_dependencies: true`); a normal run never activates
13
+ # it, so {active?} is a single nil-check and the instrumented `Scope`
14
+ # accessors pay nothing. Recording is purely observational — it never
15
+ # changes a diagnostic.
16
+ #
17
+ # Modelled on {Inference::BudgetTrace}: process-thread-local state, a
18
+ # cheap disabled fast path, and a frozen snapshot for consumers.
19
+ module DependencyRecorder
20
+ KEY = :__rigor_dependency_recorder__
21
+ private_constant :KEY
22
+
23
+ # Mutable per-consumer accumulator. Frozen into a {Record} snapshot
24
+ # when `record_for` returns.
25
+ class Accumulator
26
+ attr_reader :consumer, :sources, :missing, :symbol_sources, :ancestry_sources
27
+
28
+ def initialize(consumer)
29
+ @consumer = consumer
30
+ @sources = Set.new
31
+ @missing = Set.new
32
+ # ADR-46 slice 4 — symbol-granularity tracking.
33
+ # `symbol_sources`: source_path → Set<"ClassName#method"> for method-call deps.
34
+ # `ancestry_sources`: Set<source_path> for class-ancestry (superclass / include)
35
+ # deps — file-granularity by nature (a superclass edge touches the whole class).
36
+ @symbol_sources = Hash.new { |h, k| h[k] = Set.new }
37
+ @ancestry_sources = Set.new
38
+ end
39
+
40
+ def snapshot
41
+ frozen_sym = @symbol_sources.transform_values(&:freeze).freeze
42
+ Record.new(
43
+ consumer: consumer,
44
+ sources: sources.dup.freeze,
45
+ missing: missing.dup.freeze,
46
+ symbol_sources: frozen_sym,
47
+ ancestry_sources: ancestry_sources.dup.freeze
48
+ )
49
+ end
50
+ end
51
+
52
+ # Frozen record of one file's cross-file reads.
53
+ # `symbol_sources`: source_path → frozen Set<"ClassName#method"> (method-call edges).
54
+ # `ancestry_sources`: frozen Set<source_path> (class-ancestry edges, file-granularity).
55
+ Record = Data.define(:consumer, :sources, :missing, :symbol_sources, :ancestry_sources)
56
+
57
+ # Module-level activation count so the disabled fast path
58
+ # ({active?}) is a plain integer read rather than a `Thread.current`
59
+ # hash lookup — `user_def_for` (the instrumented accessor) is on the
60
+ # per-dispatch hot path, so a normal (non-recording) run must pay as
61
+ # little as possible. The per-thread accumulator still isolates the
62
+ # actual recording, so a non-recording thread seeing `active?` true
63
+ # (another thread is recording) just performs an extra nil-check.
64
+ @active_count = 0
65
+ @mutex = Mutex.new
66
+
67
+ module_function
68
+
69
+ # Activates recording for `consumer` (the path being analyzed) for
70
+ # the duration of the block and returns the frozen {Record}. Nests
71
+ # safely (the inner consumer's reads do not leak to the outer one);
72
+ # restores the previous recorder on exit.
73
+ def record_for(consumer)
74
+ previous = Thread.current[KEY]
75
+ accumulator = Accumulator.new(consumer.to_s)
76
+ Thread.current[KEY] = accumulator
77
+ @mutex.synchronize { @active_count += 1 }
78
+ yield
79
+ accumulator.snapshot
80
+ ensure
81
+ Thread.current[KEY] = previous
82
+ @mutex.synchronize { @active_count -= 1 }
83
+ end
84
+
85
+ # Plain integer read (GVL-atomic) — no `Thread.current` lookup on the
86
+ # disabled fast path.
87
+ def active?
88
+ @active_count.positive?
89
+ end
90
+
91
+ # Records that the current consumer read a declaration / body whose
92
+ # definition site is `path_line` (a `"path:line"` String, or nil).
93
+ # When `symbol` is given (a `"ClassName#method"` String), the read is
94
+ # a method-call edge and is recorded at symbol granularity in
95
+ # `symbol_sources` in addition to the coarse `sources` set.
96
+ # Without `symbol` the read is a class-ancestry edge (file-granularity)
97
+ # and is added to `ancestry_sources` only.
98
+ # Self-reads and nil sites are ignored in all cases.
99
+ def read_site(path_line, symbol = nil)
100
+ accumulator = Thread.current[KEY]
101
+ return if accumulator.nil? || path_line.nil?
102
+
103
+ path = path_line.split(":", 2).first
104
+ return unless path && path != accumulator.consumer
105
+
106
+ accumulator.sources << path
107
+ if symbol
108
+ accumulator.symbol_sources[path] << symbol
109
+ else
110
+ accumulator.ancestry_sources << path
111
+ end
112
+ end
113
+
114
+ # Records a cross-file lookup of `name` (kind `:method` / `:class` /
115
+ # `:const` / …) that resolved to nothing — a negative dependency.
116
+ def read_missing(kind, name)
117
+ accumulator = Thread.current[KEY]
118
+ accumulator&.missing&.add("#{kind}:#{name}")
119
+ end
120
+ end
121
+ end
122
+ end
@@ -99,6 +99,24 @@ module Rigor
99
99
  )
100
100
  end
101
101
 
102
+ # Builds a Diagnostic at a call node's `message_loc` (the
103
+ # method-name / matcher span), falling back to the receiver-
104
+ # spanning `node.location` when no message location is available.
105
+ # Absorbs the `node.message_loc || node.location` idiom the
106
+ # call-related rules otherwise repeat; all other fields forward to
107
+ # {.from_location}.
108
+ def self.from_message_loc(node, **)
109
+ from_location(node.message_loc || node.location, **)
110
+ end
111
+
112
+ # Builds a Diagnostic at a definition / assignment node's
113
+ # `name_loc` (the declared name span), falling back to
114
+ # `node.location`. Absorbs the `node.name_loc || node.location`
115
+ # idiom the def / write rules otherwise repeat.
116
+ def self.from_name_loc(node, **)
117
+ from_location(node.name_loc || node.location, **)
118
+ end
119
+
102
120
  def error?
103
121
  severity == :error
104
122
  end