rigortype 0.1.15 → 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 (220) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/exe/rigor +19 -0
  4. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +100 -0
  5. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +209 -0
  6. data/lib/rigor/analysis/check_rules.rb +174 -71
  7. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  8. data/lib/rigor/analysis/diagnostic.rb +58 -0
  9. data/lib/rigor/analysis/incremental.rb +162 -0
  10. data/lib/rigor/analysis/incremental_session.rb +337 -0
  11. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  12. data/lib/rigor/analysis/runner.rb +485 -29
  13. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  14. data/lib/rigor/analysis/worker_session.rb +3 -2
  15. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  16. data/lib/rigor/cache/descriptor.rb +56 -51
  17. data/lib/rigor/cache/incremental_snapshot.rb +147 -0
  18. data/lib/rigor/cache/rbs_cache_producer.rb +30 -0
  19. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  20. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  21. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  22. data/lib/rigor/cache/rbs_environment.rb +2 -8
  23. data/lib/rigor/cache/rbs_instance_definitions.rb +3 -16
  24. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  25. data/lib/rigor/cache/store.rb +99 -1
  26. data/lib/rigor/cli/annotate_command.rb +2 -7
  27. data/lib/rigor/cli/baseline_command.rb +2 -7
  28. data/lib/rigor/cli/command.rb +47 -0
  29. data/lib/rigor/cli/coverage_command.rb +3 -23
  30. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  31. data/lib/rigor/cli/diff_command.rb +3 -7
  32. data/lib/rigor/cli/explain_command.rb +2 -7
  33. data/lib/rigor/cli/lsp_command.rb +3 -7
  34. data/lib/rigor/cli/mcp_command.rb +3 -7
  35. data/lib/rigor/cli/options.rb +57 -0
  36. data/lib/rigor/cli/plugin_command.rb +3 -7
  37. data/lib/rigor/cli/plugins_command.rb +52 -10
  38. data/lib/rigor/cli/plugins_renderer.rb +86 -1
  39. data/lib/rigor/cli/renderable.rb +26 -0
  40. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  41. data/lib/rigor/cli/skill_command.rb +3 -7
  42. data/lib/rigor/cli/triage_command.rb +2 -7
  43. data/lib/rigor/cli/type_of_command.rb +5 -38
  44. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  45. data/lib/rigor/cli/type_scan_command.rb +3 -23
  46. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  47. data/lib/rigor/cli.rb +260 -48
  48. data/lib/rigor/configuration/dependencies.rb +18 -1
  49. data/lib/rigor/configuration/severity_profile.rb +22 -3
  50. data/lib/rigor/configuration.rb +13 -3
  51. data/lib/rigor/environment/rbs_loader.rb +335 -4
  52. data/lib/rigor/environment.rb +8 -2
  53. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  54. data/lib/rigor/inference/budget_trace.rb +137 -0
  55. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  56. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  57. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  58. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  59. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  60. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  61. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  62. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  63. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  64. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  65. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  69. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  70. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  71. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  72. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  73. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  74. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  75. data/lib/rigor/inference/expression_typer.rb +149 -22
  76. data/lib/rigor/inference/hkt_reducer.rb +2 -0
  77. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  78. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  79. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  80. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  81. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  82. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  83. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  84. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  85. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  86. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  87. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  88. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -6
  89. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +100 -23
  90. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  91. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  92. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  93. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  94. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  95. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  96. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  97. data/lib/rigor/inference/method_dispatcher.rb +147 -60
  98. data/lib/rigor/inference/narrowing.rb +202 -5
  99. data/lib/rigor/inference/precision_scanner.rb +60 -1
  100. data/lib/rigor/inference/scope_indexer.rb +257 -11
  101. data/lib/rigor/inference/statement_evaluator.rb +110 -26
  102. data/lib/rigor/inference/synthetic_method_index.rb +23 -4
  103. data/lib/rigor/inference/synthetic_method_scanner.rb +148 -14
  104. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  105. data/lib/rigor/language_server/completion_provider.rb +4 -4
  106. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  107. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  108. data/lib/rigor/language_server/hover_provider.rb +4 -4
  109. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  110. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  111. data/lib/rigor/plugin/additional_initializer.rb +108 -0
  112. data/lib/rigor/plugin/base.rb +337 -2
  113. data/lib/rigor/plugin/box.rb +64 -0
  114. data/lib/rigor/plugin/inflector.rb +121 -0
  115. data/lib/rigor/plugin/isolation.rb +191 -0
  116. data/lib/rigor/plugin/macro/nested_class_template.rb +140 -0
  117. data/lib/rigor/plugin/macro.rb +1 -0
  118. data/lib/rigor/plugin/manifest.rb +120 -23
  119. data/lib/rigor/plugin/node_context.rb +62 -0
  120. data/lib/rigor/plugin/registry.rb +49 -1
  121. data/lib/rigor/plugin.rb +3 -0
  122. data/lib/rigor/rbs_extended/conformance_checker.rb +208 -0
  123. data/lib/rigor/rbs_extended.rb +39 -0
  124. data/lib/rigor/scope.rb +123 -9
  125. data/lib/rigor/sig_gen/generator.rb +2 -3
  126. data/lib/rigor/sig_gen/observation_collector.rb +2 -2
  127. data/lib/rigor/source/literals.rb +118 -0
  128. data/lib/rigor/source/node_walker.rb +26 -0
  129. data/lib/rigor/source.rb +1 -0
  130. data/lib/rigor/type/acceptance_router.rb +19 -0
  131. data/lib/rigor/type/accepts_result.rb +3 -10
  132. data/lib/rigor/type/app.rb +3 -7
  133. data/lib/rigor/type/bot.rb +2 -3
  134. data/lib/rigor/type/bound_method.rb +5 -12
  135. data/lib/rigor/type/combinator.rb +23 -1
  136. data/lib/rigor/type/constant.rb +2 -3
  137. data/lib/rigor/type/data_class.rb +80 -0
  138. data/lib/rigor/type/data_instance.rb +100 -0
  139. data/lib/rigor/type/difference.rb +5 -10
  140. data/lib/rigor/type/dynamic.rb +5 -10
  141. data/lib/rigor/type/hash_shape.rb +5 -15
  142. data/lib/rigor/type/integer_range.rb +5 -10
  143. data/lib/rigor/type/intersection.rb +5 -10
  144. data/lib/rigor/type/nominal.rb +5 -10
  145. data/lib/rigor/type/refined.rb +5 -10
  146. data/lib/rigor/type/singleton.rb +5 -10
  147. data/lib/rigor/type/top.rb +2 -3
  148. data/lib/rigor/type/tuple.rb +5 -10
  149. data/lib/rigor/type/union.rb +69 -10
  150. data/lib/rigor/type.rb +2 -0
  151. data/lib/rigor/value_semantics.rb +77 -0
  152. data/lib/rigor/version.rb +1 -1
  153. data/lib/rigor.rb +2 -0
  154. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +31 -53
  155. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +21 -23
  156. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +38 -59
  157. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +7 -13
  158. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +22 -33
  159. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +298 -413
  160. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +69 -71
  161. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +24 -34
  162. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +18 -16
  163. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +4 -46
  164. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  165. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +1 -1
  166. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +17 -12
  167. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -8
  168. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +2 -7
  169. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +2 -6
  170. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +4 -3
  171. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  172. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +40 -45
  173. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +7 -17
  174. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +20 -42
  175. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +7 -4
  176. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +4 -8
  177. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +188 -0
  178. data/plugins/rigor-mangrove/lib/rigor-mangrove.rb +3 -0
  179. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +4 -0
  180. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +24 -8
  181. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +31 -48
  182. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +21 -23
  183. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +54 -82
  184. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +25 -25
  185. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +63 -147
  186. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -17
  187. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +23 -114
  188. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +48 -33
  189. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  190. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +6 -3
  191. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +4 -2
  192. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +13 -12
  193. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +28 -40
  194. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +44 -47
  195. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +11 -10
  196. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +45 -87
  197. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +11 -12
  198. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +29 -42
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +20 -19
  200. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +73 -0
  201. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +43 -1
  202. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +21 -29
  203. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +36 -96
  204. data/sig/rigor/cache.rbs +19 -0
  205. data/sig/rigor/inference.rbs +22 -0
  206. data/sig/rigor/plugin/access_denied_error.rbs +3 -1
  207. data/sig/rigor/plugin/base.rbs +58 -3
  208. data/sig/rigor/plugin/io_boundary.rbs +3 -0
  209. data/sig/rigor/plugin/manifest.rbs +31 -1
  210. data/sig/rigor/rbs_extended.rbs +2 -0
  211. data/sig/rigor/scope.rbs +5 -0
  212. data/sig/rigor/source.rbs +12 -0
  213. data/sig/rigor/type.rbs +58 -1
  214. data/sig/rigor.rbs +11 -1
  215. data/skills/rigor-plugin-author/SKILL.md +13 -9
  216. data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +6 -5
  217. data/skills/rigor-plugin-author/references/02-walker-and-types.md +159 -75
  218. data/skills/rigor-plugin-author/references/03-test-and-ship.md +3 -3
  219. metadata +73 -2
  220. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +0 -114
@@ -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
 
@@ -383,7 +404,16 @@ module Rigor
383
404
  # its model). Flagging an undefined method on a class
384
405
  # with an open dynamic surface is unsound, so the rule
385
406
  # skips it.
386
- return nil if open_receiver?(class_name, scope)
407
+ # An unbounded receiver surface: either a plugin-declared
408
+ # open receiver (ADR-26 — e.g. `ActiveRecord::Relation`), or
409
+ # a type Rigor synthesized (a missing-namespace module / a
410
+ # stub for a referenced-but-undeclared type) to keep a
411
+ # malformed project signature buildable. A synthesized stub's
412
+ # method table is empty only because Rigor invented it, not
413
+ # because the real type is empty (the real `DRb` has
414
+ # `start_service`), so enumerating it to prove a call
415
+ # "undefined" would be a false positive.
416
+ return nil if unbounded_receiver_surface?(class_name, scope)
387
417
 
388
418
  # Slice 7 phase 12 — suppress when the user has
389
419
  # declared the method in source (`def` /
@@ -518,11 +548,9 @@ module Rigor
518
548
  end
519
549
 
520
550
  def build_unresolved_toplevel_diagnostic(path, call_node)
521
- location = call_node.message_loc || call_node.location
522
- Diagnostic.new(
551
+ Diagnostic.from_message_loc(
552
+ call_node,
523
553
  path: path,
524
- line: location.start_line,
525
- column: location.start_column + 1,
526
554
  message: "unresolved toplevel call to `#{call_node.name}`. " \
527
555
  "If a project file defines `#{call_node.name}` via a toplevel " \
528
556
  "`def` or a monkey-patch on Object/Kernel, list that file in " \
@@ -566,6 +594,14 @@ module Rigor
566
594
  # loaded plugin (manifest `open_receivers:`). An open
567
595
  # class responds beyond its RBS surface, so the
568
596
  # `call.undefined-method` rule must not fire for it.
597
+ # True when the receiver class responds beyond an enumerable
598
+ # RBS method table, so proving a call "undefined" against it is
599
+ # unsound: a plugin-declared open receiver, or a Rigor-
600
+ # synthesized stub type (see `RbsLoader#synthesized_type_names`).
601
+ def unbounded_receiver_surface?(class_name, scope)
602
+ open_receiver?(class_name, scope) || synthesized_stub_receiver?(class_name, scope)
603
+ end
604
+
569
605
  def open_receiver?(class_name, scope)
570
606
  registry = scope.environment&.plugin_registry
571
607
  return false if registry.nil?
@@ -573,6 +609,13 @@ module Rigor
573
609
  registry.open_receiver?(class_name)
574
610
  end
575
611
 
612
+ def synthesized_stub_receiver?(class_name, scope)
613
+ loader = scope.environment&.rbs_loader
614
+ return false if loader.nil? || !loader.respond_to?(:synthesized_type_names)
615
+
616
+ loader.synthesized_type_names.include?(class_name.to_s.sub(/\A::/, ""))
617
+ end
618
+
576
619
  def definition_available?(receiver_type, class_name, scope)
577
620
  if receiver_type.is_a?(Type::Singleton)
578
621
  !Rigor::Reflection.singleton_definition(class_name, scope: scope).nil?
@@ -581,6 +624,65 @@ module Rigor
581
624
  end
582
625
  end
583
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
+
584
686
  def lookup_method(receiver_type, class_name, method_name, scope)
585
687
  if receiver_type.is_a?(Type::Singleton)
586
688
  Rigor::Reflection.singleton_method_definition(class_name, method_name, scope: scope)
@@ -808,11 +910,9 @@ module Rigor
808
910
  return nil if inside_rigor_testing?(scope)
809
911
 
810
912
  type = scope.type_of(arg)
811
- location = call_node.message_loc || call_node.location
812
- Diagnostic.new(
913
+ Diagnostic.from_message_loc(
914
+ call_node,
813
915
  path: path,
814
- line: location.start_line,
815
- column: location.start_column + 1,
816
916
  message: "dump_type: #{type.describe(:short)}",
817
917
  severity: :info,
818
918
  rule: RULE_DUMP_TYPE
@@ -905,24 +1005,20 @@ module Rigor
905
1005
  end
906
1006
 
907
1007
  def build_assert_type_diagnostic(path, call_node, expected, actual)
908
- location = call_node.message_loc || call_node.location
909
- Diagnostic.new(
1008
+ Diagnostic.from_message_loc(
1009
+ call_node,
910
1010
  rule: RULE_ASSERT_TYPE,
911
1011
  path: path,
912
- line: location.start_line,
913
- column: location.start_column + 1,
914
1012
  message: "assert_type mismatch: expected #{expected.inspect}, got #{actual.inspect}",
915
1013
  severity: :error
916
1014
  )
917
1015
  end
918
1016
 
919
1017
  def build_nil_receiver_diagnostic(path, call_node)
920
- location = call_node.message_loc || call_node.location
921
- Diagnostic.new(
1018
+ Diagnostic.from_message_loc(
1019
+ call_node,
922
1020
  rule: RULE_NIL_RECEIVER,
923
1021
  path: path,
924
- line: location.start_line,
925
- column: location.start_column + 1,
926
1022
  message: "possible nil receiver: `#{call_node.name}' is undefined on NilClass",
927
1023
  severity: :error
928
1024
  )
@@ -985,12 +1081,10 @@ module Rigor
985
1081
  end
986
1082
 
987
1083
  def build_always_raises_diagnostic(path, call_node)
988
- location = call_node.message_loc || call_node.location
989
- Diagnostic.new(
1084
+ Diagnostic.from_message_loc(
1085
+ call_node,
990
1086
  rule: RULE_ALWAYS_RAISES,
991
1087
  path: path,
992
- line: location.start_line,
993
- column: location.start_column + 1,
994
1088
  message: "always raises ZeroDivisionError: `#{call_node.name}' by zero on Integer receiver",
995
1089
  severity: :error
996
1090
  )
@@ -1109,12 +1203,10 @@ module Rigor
1109
1203
  end
1110
1204
 
1111
1205
  def build_visibility_mismatch_diagnostic(path, call_node, receiver_type)
1112
- location = call_node.message_loc || call_node.location
1113
- Diagnostic.new(
1206
+ Diagnostic.from_message_loc(
1207
+ call_node,
1114
1208
  rule: RULE_VISIBILITY_MISMATCH,
1115
1209
  path: path,
1116
- line: location.start_line,
1117
- column: location.start_column + 1,
1118
1210
  message: "private method `#{call_node.name}' called on #{receiver_type.class_name} receiver",
1119
1211
  severity: :error
1120
1212
  )
@@ -1142,36 +1234,55 @@ module Rigor
1142
1234
  end
1143
1235
 
1144
1236
  def build_always_truthy_condition_diagnostic(path, predicate_node, polarity)
1145
- location = predicate_node.location
1146
- Diagnostic.new(
1237
+ Diagnostic.from_node(
1238
+ predicate_node,
1147
1239
  rule: RULE_ALWAYS_TRUTHY_CONDITION,
1148
1240
  path: path,
1149
- line: location.start_line,
1150
- column: location.start_column + 1,
1151
1241
  message: "condition is always #{polarity} (the surrounding flow proves it folds to a constant)",
1152
1242
  severity: :warning
1153
1243
  )
1154
1244
  end
1155
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
+
1156
1271
  def build_dead_assignment_diagnostic(path, write_node, def_node)
1157
- location = write_node.name_loc || write_node.location
1158
- Diagnostic.new(
1272
+ Diagnostic.from_name_loc(
1273
+ write_node,
1159
1274
  rule: RULE_DEAD_ASSIGNMENT,
1160
1275
  path: path,
1161
- line: location.start_line,
1162
- column: location.start_column + 1,
1163
1276
  message: "local `#{write_node.name}' assigned in `#{def_node.name}' but never read",
1164
1277
  severity: :warning
1165
1278
  )
1166
1279
  end
1167
1280
 
1168
1281
  def build_ivar_write_mismatch_diagnostic(path, node, class_name, ivar_name, first_class, other_class)
1169
- location = node.name_loc || node.location
1170
- Diagnostic.new(
1282
+ Diagnostic.from_name_loc(
1283
+ node,
1171
1284
  rule: RULE_IVAR_WRITE_MISMATCH,
1172
1285
  path: path,
1173
- line: location.start_line,
1174
- column: location.start_column + 1,
1175
1286
  message: "instance variable `#{ivar_name}' on #{class_name} was previously assigned " \
1176
1287
  "#{first_class}; this write assigns #{other_class}",
1177
1288
  severity: :error
@@ -1190,12 +1301,10 @@ module Rigor
1190
1301
  end
1191
1302
 
1192
1303
  def build_unreachable_branch_diagnostic(path, dead_branch, polarity)
1193
- location = dead_branch.location
1194
- Diagnostic.new(
1304
+ Diagnostic.from_node(
1305
+ dead_branch,
1195
1306
  rule: RULE_UNREACHABLE_BRANCH,
1196
1307
  path: path,
1197
- line: location.start_line,
1198
- column: location.start_column + 1,
1199
1308
  message: "unreachable branch: literal predicate is always #{polarity}",
1200
1309
  severity: :warning
1201
1310
  )
@@ -1324,39 +1433,34 @@ module Rigor
1324
1433
  end
1325
1434
 
1326
1435
  def build_argument_type_diagnostic(path, call_node, class_name, mismatch)
1327
- location = mismatch[:node].location
1328
1436
  method_label = "`#{call_node.name}' on #{class_name}"
1329
1437
  parameter_label = mismatch[:name] ? "parameter `#{mismatch[:name]}' of #{method_label}" : method_label
1330
1438
  message = "argument type mismatch at #{parameter_label}: " \
1331
1439
  "expected #{mismatch[:expected].describe(:short)}, " \
1332
1440
  "got #{mismatch[:actual].describe(:short)}"
1333
- Diagnostic.new(
1441
+ Diagnostic.from_node(
1442
+ mismatch[:node],
1334
1443
  rule: RULE_ARGUMENT_TYPE,
1335
1444
  path: path,
1336
- line: location.start_line,
1337
- column: location.start_column + 1,
1338
1445
  message: message,
1339
1446
  severity: :error
1340
1447
  )
1341
1448
  end
1342
1449
 
1343
1450
  def build_arity_diagnostic(path, call_node, class_name, min, max, actual)
1344
- location = call_node.message_loc || call_node.location
1345
1451
  range = min == max ? min.to_s : "#{min}..#{max}"
1346
1452
  method_label = "`#{call_node.name}' on #{class_name}"
1347
1453
  message = "wrong number of arguments to #{method_label} (given #{actual}, expected #{range})"
1348
- Diagnostic.new(
1454
+ Diagnostic.from_message_loc(
1455
+ call_node,
1349
1456
  rule: RULE_WRONG_ARITY,
1350
1457
  path: path,
1351
- line: location.start_line,
1352
- column: location.start_column + 1,
1353
1458
  message: message,
1354
1459
  severity: :error
1355
1460
  )
1356
1461
  end
1357
1462
 
1358
1463
  def build_undefined_method_diagnostic(path, call_node, receiver_type, definition_site = nil, class_name = nil)
1359
- location = call_node.message_loc || call_node.location
1360
1464
  rendered_receiver = receiver_type.describe
1361
1465
  message = "undefined method `#{call_node.name}' for #{rendered_receiver}"
1362
1466
  # ADR-17 — when the project itself defines this method on the
@@ -1372,11 +1476,10 @@ module Rigor
1372
1476
  "#{definition_site} — Rigor does not apply project monkey-patches " \
1373
1477
  "cross-file; list that file in `.rigor.yml`'s `pre_eval:` (ADR-17)"
1374
1478
  end
1375
- Diagnostic.new(
1479
+ Diagnostic.from_message_loc(
1480
+ call_node,
1376
1481
  rule: RULE_UNDEFINED_METHOD,
1377
1482
  path: path,
1378
- line: location.start_line,
1379
- column: location.start_column + 1,
1380
1483
  message: message,
1381
1484
  severity: :error,
1382
1485
  receiver_type: rendered_receiver,
@@ -1520,12 +1623,10 @@ module Rigor
1520
1623
  end
1521
1624
 
1522
1625
  def build_return_type_mismatch_diagnostic(path, def_node, declared, inferred, severity)
1523
- location = def_node.name_loc || def_node.location
1524
- Diagnostic.new(
1626
+ Diagnostic.from_name_loc(
1627
+ def_node,
1525
1628
  rule: RULE_RETURN_TYPE,
1526
1629
  path: path,
1527
- line: location.start_line,
1528
- column: location.start_column + 1,
1529
1630
  message: "return-type mismatch on `#{def_node.name}': " \
1530
1631
  "declared #{declared.describe(:short)}, inferred #{inferred.describe(:short)}",
1531
1632
  severity: severity
@@ -1655,6 +1756,14 @@ module Rigor
1655
1756
  candidate = (segments[0, i] + [raw_ancestor]).join("::")
1656
1757
  return candidate if known_user_class?(scope, candidate)
1657
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?
1658
1767
  nil
1659
1768
  end
1660
1769
 
@@ -1665,12 +1774,10 @@ module Rigor
1665
1774
  end
1666
1775
 
1667
1776
  def build_override_visibility_diagnostic(path, def_node, parent_class, parent_visibility, override_visibility)
1668
- location = def_node.name_loc || def_node.location
1669
- Diagnostic.new(
1777
+ Diagnostic.from_name_loc(
1778
+ def_node,
1670
1779
  rule: RULE_OVERRIDE_VISIBILITY_REDUCED,
1671
1780
  path: path,
1672
- line: location.start_line,
1673
- column: location.start_column + 1,
1674
1781
  message: "visibility of `#{def_node.name}' reduced from #{parent_visibility} to " \
1675
1782
  "#{override_visibility} (overrides #{parent_class}##{def_node.name}); " \
1676
1783
  "breaks substitutability",
@@ -1760,12 +1867,10 @@ module Rigor
1760
1867
  end
1761
1868
 
1762
1869
  def build_override_return_widened_diagnostic(path, def_node, parent_class, parent_return, override_return)
1763
- location = def_node.name_loc || def_node.location
1764
- Diagnostic.new(
1870
+ Diagnostic.from_name_loc(
1871
+ def_node,
1765
1872
  rule: RULE_OVERRIDE_RETURN_WIDENED,
1766
1873
  path: path,
1767
- line: location.start_line,
1768
- column: location.start_column + 1,
1769
1874
  message: "return type of `#{def_node.name}' widened from #{parent_return.describe(:short)} " \
1770
1875
  "to #{override_return.describe(:short)} (overrides #{parent_class}##{def_node.name}); " \
1771
1876
  "breaks substitutability",
@@ -1866,12 +1971,10 @@ module Rigor
1866
1971
  end
1867
1972
 
1868
1973
  def build_override_param_narrowed_diagnostic(path, def_node, parent_class, index, parent_param, override_param)
1869
- location = def_node.name_loc || def_node.location
1870
- Diagnostic.new(
1974
+ Diagnostic.from_name_loc(
1975
+ def_node,
1871
1976
  rule: RULE_OVERRIDE_PARAM_NARROWED,
1872
1977
  path: path,
1873
- line: location.start_line,
1874
- column: location.start_column + 1,
1875
1978
  message: "parameter #{index + 1} of `#{def_node.name}' narrowed from " \
1876
1979
  "#{parent_param.describe(:short)} to #{override_param.describe(:short)} " \
1877
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