rigortype 0.1.16 → 0.1.18

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 (180) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
  4. data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
  5. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
  6. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +226 -0
  7. data/lib/rigor/analysis/check_rules.rb +180 -73
  8. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  9. data/lib/rigor/analysis/diagnostic.rb +18 -0
  10. data/lib/rigor/analysis/incremental.rb +162 -0
  11. data/lib/rigor/analysis/incremental_session.rb +337 -0
  12. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  13. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
  14. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  15. data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
  16. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  17. data/lib/rigor/analysis/runner.rb +477 -1110
  18. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  19. data/lib/rigor/analysis/worker_session.rb +47 -8
  20. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  21. data/lib/rigor/cache/descriptor.rb +50 -49
  22. data/lib/rigor/cache/incremental_snapshot.rb +153 -0
  23. data/lib/rigor/cache/rbs_cache_producer.rb +34 -0
  24. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  25. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  26. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  27. data/lib/rigor/cache/rbs_environment.rb +2 -8
  28. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  29. data/lib/rigor/cache/store.rb +145 -14
  30. data/lib/rigor/cli/annotate_command.rb +2 -7
  31. data/lib/rigor/cli/baseline_command.rb +2 -7
  32. data/lib/rigor/cli/check_command.rb +705 -0
  33. data/lib/rigor/cli/ci_detector.rb +94 -0
  34. data/lib/rigor/cli/command.rb +47 -0
  35. data/lib/rigor/cli/coverage_command.rb +3 -23
  36. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  37. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  38. data/lib/rigor/cli/diff_command.rb +3 -7
  39. data/lib/rigor/cli/explain_command.rb +2 -7
  40. data/lib/rigor/cli/lsp_command.rb +3 -7
  41. data/lib/rigor/cli/mcp_command.rb +3 -7
  42. data/lib/rigor/cli/options.rb +57 -0
  43. data/lib/rigor/cli/plugin_command.rb +3 -7
  44. data/lib/rigor/cli/plugins_command.rb +2 -7
  45. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  46. data/lib/rigor/cli/renderable.rb +26 -0
  47. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  48. data/lib/rigor/cli/skill_command.rb +3 -7
  49. data/lib/rigor/cli/trace_command.rb +143 -0
  50. data/lib/rigor/cli/trace_renderer.rb +310 -0
  51. data/lib/rigor/cli/triage_command.rb +2 -7
  52. data/lib/rigor/cli/type_of_command.rb +5 -38
  53. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  54. data/lib/rigor/cli/type_scan_command.rb +3 -23
  55. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  56. data/lib/rigor/cli.rb +15 -532
  57. data/lib/rigor/configuration/dependencies.rb +18 -1
  58. data/lib/rigor/configuration/severity_profile.rb +22 -3
  59. data/lib/rigor/configuration.rb +16 -3
  60. data/lib/rigor/environment/rbs_loader.rb +129 -71
  61. data/lib/rigor/environment.rb +1 -1
  62. data/lib/rigor/inference/acceptance.rb +10 -0
  63. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  64. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  65. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  69. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  70. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  71. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  72. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  73. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  74. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  75. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  76. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  77. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  78. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  79. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  80. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  81. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  82. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  83. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  84. data/lib/rigor/inference/expression_typer.rb +149 -63
  85. data/lib/rigor/inference/flow_tracer.rb +180 -0
  86. data/lib/rigor/inference/macro_block_self_type.rb +10 -11
  87. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  88. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  89. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  90. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  91. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  92. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  93. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  94. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  95. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  96. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  97. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  98. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  99. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
  100. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  101. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  102. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  103. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  104. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  105. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  106. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  107. data/lib/rigor/inference/method_dispatcher.rb +185 -84
  108. data/lib/rigor/inference/narrowing.rb +262 -5
  109. data/lib/rigor/inference/scope_indexer.rb +208 -21
  110. data/lib/rigor/inference/statement_evaluator.rb +110 -48
  111. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  112. data/lib/rigor/language_server/completion_provider.rb +4 -4
  113. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  114. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  115. data/lib/rigor/language_server/hover_provider.rb +4 -4
  116. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  117. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  118. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  119. data/lib/rigor/plugin/base.rb +302 -45
  120. data/lib/rigor/plugin/node_rule_walk.rb +147 -0
  121. data/lib/rigor/plugin/registry.rb +281 -15
  122. data/lib/rigor/plugin.rb +1 -0
  123. data/lib/rigor/rbs_extended/conformance_checker.rb +293 -0
  124. data/lib/rigor/rbs_extended.rb +39 -0
  125. data/lib/rigor/scope/discovery_index.rb +58 -0
  126. data/lib/rigor/scope.rb +150 -167
  127. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  128. data/lib/rigor/source/literals.rb +14 -0
  129. data/lib/rigor/type/acceptance_router.rb +19 -0
  130. data/lib/rigor/type/accepts_result.rb +3 -10
  131. data/lib/rigor/type/app.rb +3 -7
  132. data/lib/rigor/type/bot.rb +2 -3
  133. data/lib/rigor/type/bound_method.rb +5 -12
  134. data/lib/rigor/type/combinator.rb +22 -0
  135. data/lib/rigor/type/constant.rb +2 -3
  136. data/lib/rigor/type/data_class.rb +80 -0
  137. data/lib/rigor/type/data_instance.rb +100 -0
  138. data/lib/rigor/type/difference.rb +5 -10
  139. data/lib/rigor/type/dynamic.rb +5 -10
  140. data/lib/rigor/type/hash_shape.rb +5 -15
  141. data/lib/rigor/type/integer_range.rb +5 -10
  142. data/lib/rigor/type/intersection.rb +5 -10
  143. data/lib/rigor/type/nominal.rb +5 -10
  144. data/lib/rigor/type/refined.rb +5 -10
  145. data/lib/rigor/type/singleton.rb +5 -10
  146. data/lib/rigor/type/top.rb +2 -3
  147. data/lib/rigor/type/tuple.rb +5 -10
  148. data/lib/rigor/type/union.rb +5 -10
  149. data/lib/rigor/type.rb +2 -0
  150. data/lib/rigor/value_semantics.rb +77 -0
  151. data/lib/rigor/version.rb +1 -1
  152. data/lib/rigor.rb +1 -1
  153. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  154. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  155. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
  156. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  157. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
  158. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  159. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  160. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  161. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  163. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  164. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  166. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  167. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  168. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
  169. data/sig/rigor/cache.rbs +19 -0
  170. data/sig/rigor/environment.rbs +0 -2
  171. data/sig/rigor/inference.rbs +27 -0
  172. data/sig/rigor/plugin/base.rbs +1 -2
  173. data/sig/rigor/rbs_extended.rbs +2 -0
  174. data/sig/rigor/scope.rbs +42 -25
  175. data/sig/rigor/source.rbs +1 -0
  176. data/sig/rigor/type.rbs +58 -1
  177. data/sig/rigor.rbs +6 -1
  178. data/skills/rigor-ci-setup/SKILL.md +319 -0
  179. metadata +36 -2
  180. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -79
@@ -6,9 +6,13 @@ 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"
10
+ require_relative "check_rules/rule_walk"
9
11
  require_relative "check_rules/always_truthy_condition_collector"
12
+ require_relative "check_rules/unreachable_clause_collector"
10
13
  require_relative "check_rules/dead_assignment_collector"
11
14
  require_relative "check_rules/ivar_write_collector"
15
+ require_relative "check_rules/self_closedness_scanner"
12
16
 
13
17
  module Rigor
14
18
  module Analysis
@@ -57,6 +61,7 @@ module Rigor
57
61
  # system; new rules MUST register here so user configuration
58
62
  # can refer to them.
59
63
  RULE_UNDEFINED_METHOD = "call.undefined-method"
64
+ RULE_SELF_UNDEFINED_METHOD = "call.self-undefined-method"
60
65
  RULE_UNRESOLVED_TOPLEVEL = "call.unresolved-toplevel"
61
66
  RULE_WRONG_ARITY = "call.wrong-arity"
62
67
  RULE_ARGUMENT_TYPE = "call.argument-type-mismatch"
@@ -73,9 +78,11 @@ module Rigor
73
78
  RULE_IVAR_WRITE_MISMATCH = "def.ivar-write-mismatch"
74
79
  RULE_DEAD_ASSIGNMENT = "flow.dead-assignment"
75
80
  RULE_ALWAYS_TRUTHY_CONDITION = "flow.always-truthy-condition"
81
+ RULE_UNREACHABLE_CLAUSE = "flow.unreachable-clause"
76
82
 
77
83
  ALL_RULES = [
78
84
  RULE_UNDEFINED_METHOD,
85
+ RULE_SELF_UNDEFINED_METHOD,
79
86
  RULE_UNRESOLVED_TOPLEVEL,
80
87
  RULE_WRONG_ARITY,
81
88
  RULE_ARGUMENT_TYPE,
@@ -86,6 +93,7 @@ module Rigor
86
93
  RULE_UNREACHABLE_BRANCH,
87
94
  RULE_DEAD_ASSIGNMENT,
88
95
  RULE_ALWAYS_TRUTHY_CONDITION,
96
+ RULE_UNREACHABLE_CLAUSE,
89
97
  RULE_RETURN_TYPE,
90
98
  RULE_VISIBILITY_MISMATCH,
91
99
  RULE_OVERRIDE_VISIBILITY_REDUCED,
@@ -104,6 +112,7 @@ module Rigor
104
112
  # both spellings resolve identically.
105
113
  LEGACY_RULE_ALIASES = {
106
114
  "undefined-method" => RULE_UNDEFINED_METHOD,
115
+ "self-undefined-method" => RULE_SELF_UNDEFINED_METHOD,
107
116
  "wrong-arity" => RULE_WRONG_ARITY,
108
117
  "argument-type-mismatch" => RULE_ARGUMENT_TYPE,
109
118
  "possible-nil-receiver" => RULE_NIL_RECEIVER,
@@ -114,7 +123,8 @@ module Rigor
114
123
  "method-visibility-mismatch" => RULE_VISIBILITY_MISMATCH,
115
124
  "ivar-write-mismatch" => RULE_IVAR_WRITE_MISMATCH,
116
125
  "dead-assignment" => RULE_DEAD_ASSIGNMENT,
117
- "always-truthy-condition" => RULE_ALWAYS_TRUTHY_CONDITION
126
+ "always-truthy-condition" => RULE_ALWAYS_TRUTHY_CONDITION,
127
+ "unreachable-clause" => RULE_UNREACHABLE_CLAUSE
118
128
  }.freeze
119
129
 
120
130
  # Family wildcard — a `<family>` token in a suppression
@@ -153,7 +163,7 @@ module Rigor
153
163
  # @param root [Prism::Node]
154
164
  # @param scope_index [Hash{Prism::Node => Rigor::Scope}]
155
165
  # @return [Array<Rigor::Analysis::Diagnostic>]
156
- def diagnose(path:, root:, scope_index:, comments: [], disabled_rules: [])
166
+ def diagnose(path:, root:, scope_index:, self_call_misses: [], comments: [], disabled_rules: [])
157
167
  diagnostics = []
158
168
  Source::NodeWalker.each(root) do |node|
159
169
  case node
@@ -173,12 +183,41 @@ module Rigor
173
183
  diagnostics << unreachable if unreachable
174
184
  end
175
185
  end
176
- diagnostics.concat(always_truthy_condition_diagnostics(path, root, scope_index))
186
+ diagnostics.concat(self_undefined_method_diagnostics(path, self_call_misses, root, scope_index))
187
+ always_truthy_results, unreachable_clause_results = flow_collector_results(root, scope_index)
188
+ diagnostics.concat(always_truthy_condition_diagnostics(path, always_truthy_results))
189
+ diagnostics.concat(unreachable_clause_diagnostics(path, unreachable_clause_results))
177
190
  diagnostics.concat(ivar_write_mismatch_diagnostics(path, root, scope_index))
178
191
  diagnostics.concat(dead_assignment_diagnostics(path, root, scope_index))
179
192
  filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
180
193
  end
181
194
 
195
+ # ADR-53 Track B (slice B2) — both flow collectors ride one
196
+ # {RuleWalk} traversal instead of walking the file once each.
197
+ # Under `RIGOR_SHADOW_RULE_WALK=1` the legacy per-collector walks
198
+ # also run as the oracle and any divergence aborts the run — the
199
+ # corpus-scale half of the equivalence harness (the curated half
200
+ # is `rule_walk_equivalence_spec`).
201
+ def flow_collector_results(root, scope_index)
202
+ always_truthy = AlwaysTruthyConditionCollector.new(scope_index)
203
+ unreachable_clauses = UnreachableClauseCollector.new(scope_index)
204
+ RuleWalk.run(root, [always_truthy, unreachable_clauses])
205
+ if ENV["RIGOR_SHADOW_RULE_WALK"]
206
+ shadow_verify_flow_collectors(root, scope_index, always_truthy.results, unreachable_clauses.results)
207
+ end
208
+ [always_truthy.results, unreachable_clauses.results]
209
+ end
210
+
211
+ def shadow_verify_flow_collectors(root, scope_index, always_truthy_results, unreachable_clause_results)
212
+ legacy_always = AlwaysTruthyConditionCollector.new(scope_index).collect(root)
213
+ legacy_clauses = UnreachableClauseCollector.new(scope_index).collect(root)
214
+ return if legacy_always == always_truthy_results && legacy_clauses == unreachable_clause_results
215
+
216
+ raise "RIGOR_SHADOW_RULE_WALK divergence: always-truthy legacy=#{legacy_always.size} " \
217
+ "walk=#{always_truthy_results.size}; unreachable-clause legacy=#{legacy_clauses.size} " \
218
+ "walk=#{unreachable_clause_results.size}"
219
+ end
220
+
182
221
  def call_node_diagnostics(path, node, scope_index)
183
222
  [
184
223
  undefined_method_diagnostic(path, node, scope_index),
@@ -240,12 +279,22 @@ module Rigor
240
279
  # predicate skip envelope (see
241
280
  # `Analysis::CheckRules::AlwaysTruthyConditionCollector`
242
281
  # for the full triage rationale).
243
- def always_truthy_condition_diagnostics(path, root, scope_index)
244
- AlwaysTruthyConditionCollector.new(scope_index).collect(root).map do |result|
282
+ def always_truthy_condition_diagnostics(path, results)
283
+ results.map do |result|
245
284
  build_always_truthy_condition_diagnostic(path, result.node, result.polarity)
246
285
  end
247
286
  end
248
287
 
288
+ # ADR-47 — `flow.unreachable-clause`. One diagnostic per `when` clause
289
+ # the flow engine's narrowing proves can never match (its narrowed
290
+ # subject is `bot`). The squiggle lands on the dead clause's body,
291
+ # mirroring `flow.unreachable-branch`.
292
+ def unreachable_clause_diagnostics(path, results)
293
+ results.map do |result|
294
+ build_unreachable_clause_diagnostic(path, result)
295
+ end
296
+ end
297
+
249
298
  def ivar_mismatch_diagnostics_for(path, class_name, ivar_name, writes)
250
299
  return [] if writes.size < 2
251
300
 
@@ -527,11 +576,9 @@ module Rigor
527
576
  end
528
577
 
529
578
  def build_unresolved_toplevel_diagnostic(path, call_node)
530
- location = call_node.message_loc || call_node.location
531
- Diagnostic.new(
579
+ Diagnostic.from_message_loc(
580
+ call_node,
532
581
  path: path,
533
- line: location.start_line,
534
- column: location.start_column + 1,
535
582
  message: "unresolved toplevel call to `#{call_node.name}`. " \
536
583
  "If a project file defines `#{call_node.name}` via a toplevel " \
537
584
  "`def` or a monkey-patch on Object/Kernel, list that file in " \
@@ -605,6 +652,65 @@ module Rigor
605
652
  end
606
653
  end
607
654
 
655
+ # ADR-24 slice 4 — `call.self-undefined-method`. Consumes the engine's
656
+ # recorded unresolved implicit-self calls
657
+ # ({Analysis::SelfCallResolutionRecorder}) and adds only the
658
+ # closedness POLICY — it NEVER recomputes resolution (the reverted
659
+ # attempt-1 mistake that produced 135 FPs). A miss reaches here only
660
+ # because the engine's real resolution found the method nowhere.
661
+ #
662
+ # The v1 gate is deliberately the most conservative "confidently
663
+ # closed" shape: a STANDALONE project class — no superclass and no
664
+ # `include`/`prepend` (so its in-file method surface is complete) —
665
+ # that is not a module / mixin contract, defines no `method_missing`,
666
+ # has no dynamic `attr_*(*splat)` accessor, and is not an ADR-26 open
667
+ # receiver. Widening to superclass / include chains is a later slice,
668
+ # each behind the external corpus FP gate. Authored `:warning` but
669
+ # mapped to `:off` in every shipped profile until that gate is green
670
+ # (ADR-24 § "Slice 4"); a project opts in via `severity_overrides:`.
671
+ def self_undefined_method_diagnostics(path, self_call_misses, root, scope_index)
672
+ return [] if self_call_misses.empty?
673
+
674
+ open_names = SelfClosednessScanner.new(root).open_class_names
675
+ self_call_misses.filter_map do |miss|
676
+ next if open_names.include?(miss.class_name)
677
+
678
+ scope = scope_index[miss.node]
679
+ next if scope.nil?
680
+ next unless confidently_closed_self_class?(miss.class_name, scope)
681
+
682
+ build_self_undefined_method_diagnostic(path, miss)
683
+ end
684
+ end
685
+
686
+ def confidently_closed_self_class?(class_name, scope)
687
+ return false if unbounded_receiver_surface?(class_name, scope)
688
+ return false if scope.discovered_method?(class_name, :method_missing, :instance)
689
+ # A superclass or mixin extends the surface beyond what this file
690
+ # declares; the engine's ancestor walk may have hit an unresolvable
691
+ # ancestor, so a miss is not provably a typo. Defer to a later slice.
692
+ return false if scope.superclass_of(class_name)
693
+ return false unless scope.includes_of(class_name).empty?
694
+
695
+ true
696
+ end
697
+
698
+ def build_self_undefined_method_diagnostic(path, miss)
699
+ Diagnostic.new(
700
+ path: path,
701
+ line: miss.line || 1,
702
+ column: miss.column || 1,
703
+ message: "implicit-self call to `#{miss.method_name}` resolves to no method on " \
704
+ "`#{miss.class_name}` (a standalone class with a complete, project-known " \
705
+ "method surface). Likely a typo or a missing `def`.",
706
+ severity: :warning,
707
+ rule: RULE_SELF_UNDEFINED_METHOD,
708
+ source_family: :builtin,
709
+ receiver_type: miss.class_name,
710
+ method_name: miss.method_name
711
+ )
712
+ end
713
+
608
714
  def lookup_method(receiver_type, class_name, method_name, scope)
609
715
  if receiver_type.is_a?(Type::Singleton)
610
716
  Rigor::Reflection.singleton_method_definition(class_name, method_name, scope: scope)
@@ -832,11 +938,9 @@ module Rigor
832
938
  return nil if inside_rigor_testing?(scope)
833
939
 
834
940
  type = scope.type_of(arg)
835
- location = call_node.message_loc || call_node.location
836
- Diagnostic.new(
941
+ Diagnostic.from_message_loc(
942
+ call_node,
837
943
  path: path,
838
- line: location.start_line,
839
- column: location.start_column + 1,
840
944
  message: "dump_type: #{type.describe(:short)}",
841
945
  severity: :info,
842
946
  rule: RULE_DUMP_TYPE
@@ -929,24 +1033,20 @@ module Rigor
929
1033
  end
930
1034
 
931
1035
  def build_assert_type_diagnostic(path, call_node, expected, actual)
932
- location = call_node.message_loc || call_node.location
933
- Diagnostic.new(
1036
+ Diagnostic.from_message_loc(
1037
+ call_node,
934
1038
  rule: RULE_ASSERT_TYPE,
935
1039
  path: path,
936
- line: location.start_line,
937
- column: location.start_column + 1,
938
1040
  message: "assert_type mismatch: expected #{expected.inspect}, got #{actual.inspect}",
939
1041
  severity: :error
940
1042
  )
941
1043
  end
942
1044
 
943
1045
  def build_nil_receiver_diagnostic(path, call_node)
944
- location = call_node.message_loc || call_node.location
945
- Diagnostic.new(
1046
+ Diagnostic.from_message_loc(
1047
+ call_node,
946
1048
  rule: RULE_NIL_RECEIVER,
947
1049
  path: path,
948
- line: location.start_line,
949
- column: location.start_column + 1,
950
1050
  message: "possible nil receiver: `#{call_node.name}' is undefined on NilClass",
951
1051
  severity: :error
952
1052
  )
@@ -1009,12 +1109,10 @@ module Rigor
1009
1109
  end
1010
1110
 
1011
1111
  def build_always_raises_diagnostic(path, call_node)
1012
- location = call_node.message_loc || call_node.location
1013
- Diagnostic.new(
1112
+ Diagnostic.from_message_loc(
1113
+ call_node,
1014
1114
  rule: RULE_ALWAYS_RAISES,
1015
1115
  path: path,
1016
- line: location.start_line,
1017
- column: location.start_column + 1,
1018
1116
  message: "always raises ZeroDivisionError: `#{call_node.name}' by zero on Integer receiver",
1019
1117
  severity: :error
1020
1118
  )
@@ -1133,12 +1231,10 @@ module Rigor
1133
1231
  end
1134
1232
 
1135
1233
  def build_visibility_mismatch_diagnostic(path, call_node, receiver_type)
1136
- location = call_node.message_loc || call_node.location
1137
- Diagnostic.new(
1234
+ Diagnostic.from_message_loc(
1235
+ call_node,
1138
1236
  rule: RULE_VISIBILITY_MISMATCH,
1139
1237
  path: path,
1140
- line: location.start_line,
1141
- column: location.start_column + 1,
1142
1238
  message: "private method `#{call_node.name}' called on #{receiver_type.class_name} receiver",
1143
1239
  severity: :error
1144
1240
  )
@@ -1166,36 +1262,55 @@ module Rigor
1166
1262
  end
1167
1263
 
1168
1264
  def build_always_truthy_condition_diagnostic(path, predicate_node, polarity)
1169
- location = predicate_node.location
1170
- Diagnostic.new(
1265
+ Diagnostic.from_node(
1266
+ predicate_node,
1171
1267
  rule: RULE_ALWAYS_TRUTHY_CONDITION,
1172
1268
  path: path,
1173
- line: location.start_line,
1174
- column: location.start_column + 1,
1175
1269
  message: "condition is always #{polarity} (the surrounding flow proves it folds to a constant)",
1176
1270
  severity: :warning
1177
1271
  )
1178
1272
  end
1179
1273
 
1274
+ def build_unreachable_clause_diagnostic(path, result)
1275
+ Diagnostic.from_node(
1276
+ result.body,
1277
+ rule: RULE_UNREACHABLE_CLAUSE,
1278
+ path: path,
1279
+ message: unreachable_clause_message(result),
1280
+ severity: :warning
1281
+ )
1282
+ end
1283
+
1284
+ def unreachable_clause_message(result)
1285
+ subject = result.subject_name
1286
+ kw = result.keyword
1287
+ case result.kind
1288
+ when :prior_exhaustion
1289
+ "unreachable `#{kw} #{result.condition_source}': `#{subject}' is already covered " \
1290
+ "by an earlier `#{kw}' clause"
1291
+ when :exhausted_else
1292
+ "unreachable `else': the `#{kw}' clauses already cover every value `#{subject}' can take here"
1293
+ else # :disjoint
1294
+ "unreachable `#{kw} #{result.condition_source}': `#{subject}' can never be " \
1295
+ "#{result.condition_source} here (the flow proves the subject disjoint)"
1296
+ end
1297
+ end
1298
+
1180
1299
  def build_dead_assignment_diagnostic(path, write_node, def_node)
1181
- location = write_node.name_loc || write_node.location
1182
- Diagnostic.new(
1300
+ Diagnostic.from_name_loc(
1301
+ write_node,
1183
1302
  rule: RULE_DEAD_ASSIGNMENT,
1184
1303
  path: path,
1185
- line: location.start_line,
1186
- column: location.start_column + 1,
1187
1304
  message: "local `#{write_node.name}' assigned in `#{def_node.name}' but never read",
1188
1305
  severity: :warning
1189
1306
  )
1190
1307
  end
1191
1308
 
1192
1309
  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(
1310
+ Diagnostic.from_name_loc(
1311
+ node,
1195
1312
  rule: RULE_IVAR_WRITE_MISMATCH,
1196
1313
  path: path,
1197
- line: location.start_line,
1198
- column: location.start_column + 1,
1199
1314
  message: "instance variable `#{ivar_name}' on #{class_name} was previously assigned " \
1200
1315
  "#{first_class}; this write assigns #{other_class}",
1201
1316
  severity: :error
@@ -1214,12 +1329,10 @@ module Rigor
1214
1329
  end
1215
1330
 
1216
1331
  def build_unreachable_branch_diagnostic(path, dead_branch, polarity)
1217
- location = dead_branch.location
1218
- Diagnostic.new(
1332
+ Diagnostic.from_node(
1333
+ dead_branch,
1219
1334
  rule: RULE_UNREACHABLE_BRANCH,
1220
1335
  path: path,
1221
- line: location.start_line,
1222
- column: location.start_column + 1,
1223
1336
  message: "unreachable branch: literal predicate is always #{polarity}",
1224
1337
  severity: :warning
1225
1338
  )
@@ -1348,39 +1461,34 @@ module Rigor
1348
1461
  end
1349
1462
 
1350
1463
  def build_argument_type_diagnostic(path, call_node, class_name, mismatch)
1351
- location = mismatch[:node].location
1352
1464
  method_label = "`#{call_node.name}' on #{class_name}"
1353
1465
  parameter_label = mismatch[:name] ? "parameter `#{mismatch[:name]}' of #{method_label}" : method_label
1354
1466
  message = "argument type mismatch at #{parameter_label}: " \
1355
1467
  "expected #{mismatch[:expected].describe(:short)}, " \
1356
1468
  "got #{mismatch[:actual].describe(:short)}"
1357
- Diagnostic.new(
1469
+ Diagnostic.from_node(
1470
+ mismatch[:node],
1358
1471
  rule: RULE_ARGUMENT_TYPE,
1359
1472
  path: path,
1360
- line: location.start_line,
1361
- column: location.start_column + 1,
1362
1473
  message: message,
1363
1474
  severity: :error
1364
1475
  )
1365
1476
  end
1366
1477
 
1367
1478
  def build_arity_diagnostic(path, call_node, class_name, min, max, actual)
1368
- location = call_node.message_loc || call_node.location
1369
1479
  range = min == max ? min.to_s : "#{min}..#{max}"
1370
1480
  method_label = "`#{call_node.name}' on #{class_name}"
1371
1481
  message = "wrong number of arguments to #{method_label} (given #{actual}, expected #{range})"
1372
- Diagnostic.new(
1482
+ Diagnostic.from_message_loc(
1483
+ call_node,
1373
1484
  rule: RULE_WRONG_ARITY,
1374
1485
  path: path,
1375
- line: location.start_line,
1376
- column: location.start_column + 1,
1377
1486
  message: message,
1378
1487
  severity: :error
1379
1488
  )
1380
1489
  end
1381
1490
 
1382
1491
  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
1492
  rendered_receiver = receiver_type.describe
1385
1493
  message = "undefined method `#{call_node.name}' for #{rendered_receiver}"
1386
1494
  # ADR-17 — when the project itself defines this method on the
@@ -1396,11 +1504,10 @@ module Rigor
1396
1504
  "#{definition_site} — Rigor does not apply project monkey-patches " \
1397
1505
  "cross-file; list that file in `.rigor.yml`'s `pre_eval:` (ADR-17)"
1398
1506
  end
1399
- Diagnostic.new(
1507
+ Diagnostic.from_message_loc(
1508
+ call_node,
1400
1509
  rule: RULE_UNDEFINED_METHOD,
1401
1510
  path: path,
1402
- line: location.start_line,
1403
- column: location.start_column + 1,
1404
1511
  message: message,
1405
1512
  severity: :error,
1406
1513
  receiver_type: rendered_receiver,
@@ -1544,12 +1651,10 @@ module Rigor
1544
1651
  end
1545
1652
 
1546
1653
  def build_return_type_mismatch_diagnostic(path, def_node, declared, inferred, severity)
1547
- location = def_node.name_loc || def_node.location
1548
- Diagnostic.new(
1654
+ Diagnostic.from_name_loc(
1655
+ def_node,
1549
1656
  rule: RULE_RETURN_TYPE,
1550
1657
  path: path,
1551
- line: location.start_line,
1552
- column: location.start_column + 1,
1553
1658
  message: "return-type mismatch on `#{def_node.name}': " \
1554
1659
  "declared #{declared.describe(:short)}, inferred #{inferred.describe(:short)}",
1555
1660
  severity: severity
@@ -1679,6 +1784,14 @@ module Rigor
1679
1784
  candidate = (segments[0, i] + [raw_ancestor]).join("::")
1680
1785
  return candidate if known_user_class?(scope, candidate)
1681
1786
  end
1787
+ # ADR-46 slice 3 — the override checker reads the class graph
1788
+ # directly (not through the recorder's `Scope` choke points), and
1789
+ # short-circuits when the ancestor resolves to no project class, so
1790
+ # an incremental re-check has no edge telling it to re-check this
1791
+ # subclass when that ancestor is later defined. Record a negative
1792
+ # class edge (keyed on the unqualified name) so the appeared-class
1793
+ # widening picks it up.
1794
+ DependencyRecorder.read_missing(:class, raw_ancestor.to_s.split("::").last) if DependencyRecorder.active?
1682
1795
  nil
1683
1796
  end
1684
1797
 
@@ -1689,12 +1802,10 @@ module Rigor
1689
1802
  end
1690
1803
 
1691
1804
  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(
1805
+ Diagnostic.from_name_loc(
1806
+ def_node,
1694
1807
  rule: RULE_OVERRIDE_VISIBILITY_REDUCED,
1695
1808
  path: path,
1696
- line: location.start_line,
1697
- column: location.start_column + 1,
1698
1809
  message: "visibility of `#{def_node.name}' reduced from #{parent_visibility} to " \
1699
1810
  "#{override_visibility} (overrides #{parent_class}##{def_node.name}); " \
1700
1811
  "breaks substitutability",
@@ -1784,12 +1895,10 @@ module Rigor
1784
1895
  end
1785
1896
 
1786
1897
  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(
1898
+ Diagnostic.from_name_loc(
1899
+ def_node,
1789
1900
  rule: RULE_OVERRIDE_RETURN_WIDENED,
1790
1901
  path: path,
1791
- line: location.start_line,
1792
- column: location.start_column + 1,
1793
1902
  message: "return type of `#{def_node.name}' widened from #{parent_return.describe(:short)} " \
1794
1903
  "to #{override_return.describe(:short)} (overrides #{parent_class}##{def_node.name}); " \
1795
1904
  "breaks substitutability",
@@ -1890,12 +1999,10 @@ module Rigor
1890
1999
  end
1891
2000
 
1892
2001
  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(
2002
+ Diagnostic.from_name_loc(
2003
+ def_node,
1895
2004
  rule: RULE_OVERRIDE_PARAM_NARROWED,
1896
2005
  path: path,
1897
- line: location.start_line,
1898
- column: location.start_column + 1,
1899
2006
  message: "parameter #{index + 1} of `#{def_node.name}' narrowed from " \
1900
2007
  "#{parent_param.describe(:short)} to #{override_param.describe(:short)} " \
1901
2008
  "(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