rigortype 0.2.9 → 0.3.1

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 (222) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/string_scanner.rbs +6 -5
  15. data/docs/handbook/01-getting-started.md +22 -34
  16. data/docs/handbook/03-narrowing.md +2 -1
  17. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  18. data/docs/handbook/06-classes.md +19 -10
  19. data/docs/handbook/07-rbs-and-extended.md +76 -101
  20. data/docs/handbook/08-understanding-errors.md +114 -246
  21. data/docs/handbook/09-plugins.md +54 -144
  22. data/docs/handbook/11-sig-gen.md +11 -1
  23. data/docs/handbook/README.md +5 -3
  24. data/docs/handbook/appendix-liskov.md +4 -2
  25. data/docs/handbook/appendix-mypy.md +4 -3
  26. data/docs/handbook/appendix-phpstan.md +14 -7
  27. data/docs/handbook/appendix-steep.md +4 -2
  28. data/docs/handbook/appendix-type-theory.md +3 -1
  29. data/docs/install.md +14 -6
  30. data/docs/manual/01-installation.md +98 -4
  31. data/docs/manual/02-cli-reference.md +81 -12
  32. data/docs/manual/03-configuration.md +34 -2
  33. data/docs/manual/04-diagnostics.md +59 -4
  34. data/docs/manual/06-baseline.md +35 -1
  35. data/docs/manual/07-plugins.md +4 -4
  36. data/docs/manual/08-skills.md +6 -1
  37. data/docs/manual/09-editor-integration.md +7 -6
  38. data/docs/manual/11-ci.md +51 -0
  39. data/docs/manual/12-caching.md +65 -0
  40. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  41. data/docs/manual/plugins/rigor-devise.md +4 -2
  42. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  43. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  44. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  45. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  46. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  47. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  48. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  49. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  50. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  51. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  52. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  53. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +112 -0
  54. data/lib/rigor/analysis/check_rules.rb +581 -98
  55. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  56. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  57. data/lib/rigor/analysis/incremental_session.rb +456 -51
  58. data/lib/rigor/analysis/path_expansion.rb +42 -0
  59. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  60. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  61. data/lib/rigor/analysis/run_cache_key.rb +78 -0
  62. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  63. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  64. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  65. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  66. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  67. data/lib/rigor/analysis/runner.rb +344 -68
  68. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  69. data/lib/rigor/analysis/worker_session.rb +30 -2
  70. data/lib/rigor/bleeding_edge.rb +37 -8
  71. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  72. data/lib/rigor/cache/descriptor.rb +88 -28
  73. data/lib/rigor/cache/file_digest.rb +158 -0
  74. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  75. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  76. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  77. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  78. data/lib/rigor/cache/store.rb +132 -27
  79. data/lib/rigor/ci_detector.rb +90 -0
  80. data/lib/rigor/cli/annotate_command.rb +15 -9
  81. data/lib/rigor/cli/baseline_command.rb +4 -1
  82. data/lib/rigor/cli/check_command.rb +223 -32
  83. data/lib/rigor/cli/check_invocation.rb +84 -0
  84. data/lib/rigor/cli/coverage_command.rb +6 -1
  85. data/lib/rigor/cli/docs_command.rb +0 -30
  86. data/lib/rigor/cli/doctor_command.rb +169 -31
  87. data/lib/rigor/cli/lsp_command.rb +5 -0
  88. data/lib/rigor/cli/mcp_command.rb +5 -0
  89. data/lib/rigor/cli/plugins_command.rb +36 -6
  90. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  91. data/lib/rigor/cli/probe_environment.rb +85 -0
  92. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  93. data/lib/rigor/cli/skill_command.rb +22 -33
  94. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  95. data/lib/rigor/cli/skill_describe.rb +75 -9
  96. data/lib/rigor/cli/trace_command.rb +5 -6
  97. data/lib/rigor/cli/type_of_command.rb +9 -8
  98. data/lib/rigor/cli/type_scan_command.rb +8 -8
  99. data/lib/rigor/cli.rb +15 -4
  100. data/lib/rigor/config_audit.rb +48 -2
  101. data/lib/rigor/configuration/severity_profile.rb +31 -0
  102. data/lib/rigor/configuration.rb +156 -8
  103. data/lib/rigor/environment/default_libraries.rb +36 -0
  104. data/lib/rigor/environment/rbs_loader.rb +164 -5
  105. data/lib/rigor/environment.rb +29 -28
  106. data/lib/rigor/inference/budget_trace.rb +77 -2
  107. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  108. data/lib/rigor/inference/def_handle.rb +23 -0
  109. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  110. data/lib/rigor/inference/def_return_typer.rb +2 -1
  111. data/lib/rigor/inference/expression_typer.rb +361 -113
  112. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  113. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  114. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  115. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  116. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +17 -1
  117. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  118. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  119. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  120. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  121. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  122. data/lib/rigor/inference/mutation_widening.rb +42 -8
  123. data/lib/rigor/inference/narrowing.rb +127 -17
  124. data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
  125. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  126. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  127. data/lib/rigor/inference/scope_indexer.rb +451 -36
  128. data/lib/rigor/inference/statement_evaluator.rb +64 -15
  129. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  130. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  131. data/lib/rigor/inference/void_origin.rb +25 -0
  132. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  133. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  134. data/lib/rigor/language_server/buffer_table.rb +46 -6
  135. data/lib/rigor/language_server/completion_provider.rb +2 -1
  136. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
  137. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  138. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  139. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  140. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  141. data/lib/rigor/language_server/server.rb +19 -9
  142. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  143. data/lib/rigor/language_server.rb +1 -0
  144. data/lib/rigor/plugin/base.rb +38 -24
  145. data/lib/rigor/plugin/inflector.rb +12 -3
  146. data/lib/rigor/plugin/io_boundary.rb +5 -1
  147. data/lib/rigor/plugin/isolation.rb +81 -11
  148. data/lib/rigor/plugin/load_error.rb +10 -1
  149. data/lib/rigor/plugin/loader.rb +96 -14
  150. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  151. data/lib/rigor/plugin/registry.rb +32 -23
  152. data/lib/rigor/plugin.rb +26 -0
  153. data/lib/rigor/protection/mutator.rb +3 -2
  154. data/lib/rigor/reflection.rb +64 -0
  155. data/lib/rigor/runtime/jit.rb +128 -0
  156. data/lib/rigor/scope/discovery_index.rb +12 -2
  157. data/lib/rigor/scope.rb +122 -28
  158. data/lib/rigor/sig_gen/classification.rb +5 -1
  159. data/lib/rigor/sig_gen/generator.rb +38 -6
  160. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  161. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  162. data/lib/rigor/sig_gen/renderer.rb +10 -0
  163. data/lib/rigor/sig_gen/write_result.rb +9 -4
  164. data/lib/rigor/sig_gen/writer.rb +207 -49
  165. data/lib/rigor/source/node_children.rb +116 -0
  166. data/lib/rigor/source/node_locator.rb +3 -1
  167. data/lib/rigor/source/node_walker.rb +4 -2
  168. data/lib/rigor/source.rb +1 -0
  169. data/lib/rigor/type/difference.rb +28 -24
  170. data/lib/rigor/type/hash_shape.rb +34 -10
  171. data/lib/rigor/version.rb +1 -1
  172. data/lib/rigortype.rb +24 -0
  173. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  174. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  175. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  176. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  177. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  178. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
  179. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
  180. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  181. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  182. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  183. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  184. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  185. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  186. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  187. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  188. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  189. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  190. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
  191. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  192. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
  193. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  194. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  195. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  196. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  197. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  198. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  199. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  200. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  201. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  202. data/sig/prism_node_children.rbs +9 -0
  203. data/sig/rigor/cache.rbs +6 -0
  204. data/sig/rigor/inference/void_origin.rbs +18 -0
  205. data/sig/rigor/inference.rbs +7 -0
  206. data/sig/rigor/plugin/base.rbs +6 -6
  207. data/sig/rigor/reflection.rbs +1 -0
  208. data/sig/rigor/scope.rbs +12 -1
  209. data/skills/rigor-ci-setup/SKILL.md +10 -0
  210. data/skills/rigor-editor-setup/SKILL.md +11 -0
  211. data/skills/rigor-next-steps/SKILL.md +23 -2
  212. data/skills/rigor-plugin-author/SKILL.md +3 -3
  213. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  214. data/skills/rigor-plugin-review/SKILL.md +2 -2
  215. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  216. data/skills/rigor-project-init/SKILL.md +29 -3
  217. data/skills/rigor-project-init/references/01-detect.md +1 -1
  218. data/skills/rigor-project-init/references/02-configure.md +39 -5
  219. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  220. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  221. metadata +48 -26
  222. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ module CheckRules
8
+ # ADR-67 WD6b — the shared "is this node rooted at an inferred parameter?" predicate.
9
+ #
10
+ # When the `parameter_inference:` gate seeds an undeclared parameter from its call-site union, that type
11
+ # is a *lower bound* (only the resolved call sites contribute; an unseen / dynamic caller could pass any
12
+ # wider value). A diagnostic decided against a lower bound is a false positive by construction — the same
13
+ # reasoning that keeps ADR-67 WD1 non-negotiable at the parameter boundary. So every negative in-body
14
+ # rule must decline when the value it reasons about is (transitively) an inferred parameter.
15
+ #
16
+ # `rooted?` walks a receiver / argument / condition-subject node down to its syntactic root and reports
17
+ # whether that root is a pristine inferred-parameter local. It is purely syntactic — a bounded AST walk
18
+ # over `.receiver` — not a value-flow provenance channel (the mark stays on the direct parameter local
19
+ # in the scope; this reads the chain root at the firing site). It covers:
20
+ #
21
+ # - the direct receiver / subject: `param.foo`, `case param`
22
+ # - an indexed element: `param[i] - x` (the `-` receiver is the `[]` call whose root is `param`)
23
+ # - any method chain: `param.foo.bar`, `param.foo(arg).baz`, `param.key?(:x)`
24
+ #
25
+ # Every value derived from an inferred parameter by dispatch is itself a lower bound, so firing against it
26
+ # is an FP by the same construction. No-op on a normal `check` run — `Scope#inferred_param?` is false
27
+ # unless the gate seeded the table.
28
+ module InferredParamGuard
29
+ module_function
30
+
31
+ # A defensive depth cap against a pathological receiver chain (the walk is otherwise linear in chain
32
+ # length, which is tiny in practice).
33
+ MAX_DEPTH = 64
34
+
35
+ def rooted?(node, scope, depth = 0)
36
+ return false if node.nil? || depth > MAX_DEPTH
37
+
38
+ case node
39
+ when Prism::LocalVariableReadNode then scope.inferred_param?(node.name)
40
+ when Prism::CallNode then rooted?(node.receiver, scope, depth + 1)
41
+ # Value-combining forms: the produced value is a union of the operands, so it is a lower bound when
42
+ # ANY operand is inferred-parameter-rooted (`cps[i] - x rescue -1`, `a && param.foo`, `param || b`).
43
+ # Declining on the whole expression is FP-safe by the same lower-bound argument.
44
+ when Prism::RescueModifierNode
45
+ rooted?(node.expression, scope, depth + 1) || rooted?(node.rescue_expression, scope, depth + 1)
46
+ when Prism::AndNode, Prism::OrNode
47
+ rooted?(node.left, scope, depth + 1) || rooted?(node.right, scope, depth + 1)
48
+ when Prism::ParenthesesNode then rooted?(last_statement(node.body), scope, depth + 1)
49
+ when Prism::StatementsNode then rooted?(node.body.last, scope, depth + 1)
50
+ else false
51
+ end
52
+ end
53
+
54
+ def last_statement(body)
55
+ body.is_a?(Prism::StatementsNode) ? body.body.last : body
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -3,6 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "../../source/constant_path"
6
+ require_relative "../../source/node_children"
6
7
 
7
8
  module Rigor
8
9
  module Analysis
@@ -78,7 +79,7 @@ module Rigor
78
79
  return
79
80
  end
80
81
 
81
- node.compact_child_nodes.each { |child| walk(child, qualified_prefix) }
82
+ node.rigor_each_child { |child| walk(child, qualified_prefix) }
82
83
  end
83
84
 
84
85
  def collect_def_writes(def_node, qualified_prefix)
@@ -95,7 +96,7 @@ module Rigor
95
96
  record_write(node, class_name) if node.is_a?(Prism::InstanceVariableWriteNode)
96
97
  return if BARRIER_NODES.any? { |klass| node.is_a?(klass) }
97
98
 
98
- node.compact_child_nodes.each { |child| gather_writes(child, class_name) }
99
+ node.rigor_each_child { |child| gather_writes(child, class_name) }
99
100
  end
100
101
 
101
102
  def record_write(node, class_name)
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../../source/node_children"
6
+
7
+ module Rigor
8
+ module Analysis
9
+ module CheckRules
10
+ # Walks a parse tree and collects every explicit `ReturnNode` that sits lexically inside the `ensure`
11
+ # clause of a `begin` / `def` / `class` body. In Ruby, `return` inside `ensure` silently discards
12
+ # both the method's in-flight return value and any in-flight exception — the classic footgun RuboCop
13
+ # ships as `Lint/EnsureReturn` and PHPStan as `OverwrittenExitPointByFinallyRule`.
14
+ #
15
+ # The check is purely syntactic (no type engine involved) with a frame-aware envelope so it only
16
+ # flags a `return` that actually belongs to the frame whose `ensure` it sits in:
17
+ #
18
+ # - A `return` inside a nested `def`, a lambda (`-> {}` / `lambda {}`), or a `define_method` block
19
+ # within the ensure body is skipped — that `return` exits the inner frame, not the one whose
20
+ # in-flight exception the ensure guards.
21
+ # - A `return` inside a *plain* block (`each do ... return ... end`) within the ensure body DOES
22
+ # belong to the enclosing method and fires.
23
+ # - A nested `begin ... ensure ... end` *inside* an ensure body is not descended into at its own
24
+ # `ensure` clause — that inner `EnsureNode` is visited separately (every `BeginNode` is), so each
25
+ # offending `return` is collected exactly once.
26
+ class ReturnInEnsureCollector
27
+ # ADR-53 Track B — the node classes the shared {RuleWalk} dispatches to this collector. Prism
28
+ # represents every construct that can carry an `ensure` clause — `begin ... end`, a `def` body with
29
+ # `rescue` / `ensure`, a class / module body with them — as a `BeginNode`, so one dispatch class
30
+ # covers all the syntactic hosts. No context gates: the footgun is frame-relative, not
31
+ # position-relative, so a `begin/ensure` inside a loop, block, or lambda is checked the same way.
32
+ NODE_CLASSES = [Prism::BeginNode].freeze
33
+
34
+ # Receiver-less calls whose attached block opens a new return frame: a `return` inside their block
35
+ # exits the lambda / defined method, not the method whose `ensure` we are scanning. `proc` is
36
+ # deliberately absent — `return` inside a `Proc` block returns from the enclosing method, so it
37
+ # stays in scope for the rule.
38
+ FRAME_BARRIER_CALL_NAMES = %i[lambda define_method].freeze
39
+
40
+ def initialize(scope_index)
41
+ @scope_index = scope_index
42
+ @results = []
43
+ end
44
+
45
+ # Legacy single-collector walk — the oracle the ADR-53 Track B equivalence harness compares
46
+ # {RuleWalk} against.
47
+ def collect(root)
48
+ walk_for_begin_nodes(root)
49
+ @results.freeze
50
+ end
51
+
52
+ # {RuleWalk} entry point: invoked at every `BeginNode` the shared traversal reaches. The `context`
53
+ # is unused — this collector checks every ensure clause.
54
+ def visit(begin_node, _context = nil)
55
+ collect_ensure_returns(begin_node)
56
+ end
57
+
58
+ # Returns `Array<{return_node:}>` — one entry per offending `return`, frozen the same way
59
+ # `#collect` returns it.
60
+ def results
61
+ @results.freeze
62
+ end
63
+
64
+ private
65
+
66
+ def walk_for_begin_nodes(node)
67
+ return unless node.is_a?(Prism::Node)
68
+
69
+ collect_ensure_returns(node) if node.is_a?(Prism::BeginNode)
70
+ node.rigor_each_child { |child| walk_for_begin_nodes(child) }
71
+ end
72
+
73
+ def collect_ensure_returns(begin_node)
74
+ ensure_clause = begin_node.ensure_clause
75
+ return if ensure_clause.nil?
76
+
77
+ gather_returns(ensure_clause.statements)
78
+ end
79
+
80
+ def gather_returns(node)
81
+ return unless node.is_a?(Prism::Node)
82
+
83
+ case node
84
+ when Prism::ReturnNode
85
+ @results << { return_node: node }
86
+ when Prism::DefNode, Prism::LambdaNode, Prism::EnsureNode
87
+ # Def / lambda open a new return frame — a `return` below them exits the nested frame, not the
88
+ # one whose ensure we are scanning. A nested `begin/ensure`'s own EnsureNode is skipped for a
89
+ # different reason: its BeginNode is visited separately, which collects those returns once —
90
+ # descending here would double-collect them.
91
+ return
92
+ when Prism::CallNode
93
+ return gather_returns_around_barrier_block(node) if frame_barrier_call?(node)
94
+ end
95
+
96
+ node.rigor_each_child { |child| gather_returns(child) }
97
+ end
98
+
99
+ # `lambda { ... }` / `define_method(:x) { ... }`: the receiver and arguments stay in the current
100
+ # frame (and may themselves contain an offending `return`), only the attached block opens a new
101
+ # one — descend into everything except the block.
102
+ def gather_returns_around_barrier_block(call_node)
103
+ barrier_block = call_node.block
104
+ call_node.rigor_each_child do |child|
105
+ gather_returns(child) unless child.equal?(barrier_block)
106
+ end
107
+ end
108
+
109
+ def frame_barrier_call?(call_node)
110
+ call_node.receiver.nil? &&
111
+ FRAME_BARRIER_CALL_NAMES.include?(call_node.name) &&
112
+ call_node.block.is_a?(Prism::BlockNode)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Analysis
5
+ module CheckRules
6
+ # Canonical identifiers for each rule. Per ADR-8 § "Diagnostic ID family hierarchy", rule names are
7
+ # `family.rule-name` two-segment strings; the families group diagnostics by where they originate
8
+ # (`call.*` for call-site rules, `flow.*` for flow-analysis proofs, `assert.*` for runtime-assertion
9
+ # rules, `dump.*` for debug helpers, `def.*` for method-definition rules). Used by the configuration
10
+ # `disable:` list and the in-source `# rigor:disable <rule>` suppression comment system; new rules MUST
11
+ # register here so user configuration can refer to them.
12
+ #
13
+ # ADR-87 WD4 — this pure-data constant table is split out of the (engine-heavy) `check_rules.rb` so
14
+ # {Analysis::RuleCatalog} — which the CLI's JSON `evidence_tier` / `documentation_url` enrichment reads
15
+ # — can require it WITHOUT loading the inference engine. The boot-slimming hit path stays engine-free
16
+ # even when it formats JSON.
17
+ RULE_UNDEFINED_METHOD = "call.undefined-method"
18
+ RULE_SELF_UNDEFINED_METHOD = "call.self-undefined-method"
19
+ RULE_UNRESOLVED_TOPLEVEL = "call.unresolved-toplevel"
20
+ RULE_WRONG_ARITY = "call.wrong-arity"
21
+ RULE_ARGUMENT_TYPE = "call.argument-type-mismatch"
22
+ RULE_NIL_RECEIVER = "call.possible-nil-receiver"
23
+ RULE_RAISE_NON_EXCEPTION = "call.raise-non-exception"
24
+ RULE_DUMP_TYPE = "dump.type"
25
+ RULE_ASSERT_TYPE = "assert.type-mismatch"
26
+ RULE_ALWAYS_RAISES = "flow.always-raises"
27
+ RULE_UNREACHABLE_BRANCH = "flow.unreachable-branch"
28
+ RULE_RETURN_TYPE = "def.return-type-mismatch"
29
+ RULE_VISIBILITY_MISMATCH = "def.method-visibility-mismatch"
30
+ RULE_OVERRIDE_VISIBILITY_REDUCED = "def.override-visibility-reduced"
31
+ RULE_OVERRIDE_RETURN_WIDENED = "def.override-return-widened"
32
+ RULE_OVERRIDE_PARAM_NARROWED = "def.override-param-narrowed"
33
+ RULE_IVAR_WRITE_MISMATCH = "def.ivar-write-mismatch"
34
+ RULE_DEAD_ASSIGNMENT = "flow.dead-assignment"
35
+ RULE_ALWAYS_TRUTHY_CONDITION = "flow.always-truthy-condition"
36
+ RULE_UNREACHABLE_CLAUSE = "flow.unreachable-clause"
37
+ RULE_DUPLICATE_HASH_KEY = "flow.duplicate-hash-key"
38
+ RULE_RETURN_IN_ENSURE = "flow.return-in-ensure"
39
+ RULE_SHADOWED_RESCUE_CLAUSE = "flow.shadowed-rescue-clause"
40
+ RULE_SUPPRESSION_UNKNOWN_RULE = "suppression.unknown-rule"
41
+ RULE_SUPPRESSION_EMPTY = "suppression.empty"
42
+ RULE_SUPPRESSION_UNKNOWN_MARKER = "suppression.unknown-marker"
43
+ # ADR-100 — the first `static.value-use.*` id: a value recovered from an author-declared `-> void`
44
+ # return, used in value context. Authored `:warning`, resolved `:off` by every profile and promoted to
45
+ # `:warning` only by the `use-of-void-value` bleeding-edge feature.
46
+ RULE_VALUE_USE_VOID = "static.value-use.void"
47
+
48
+ ALL_RULES = [
49
+ RULE_UNDEFINED_METHOD,
50
+ RULE_SELF_UNDEFINED_METHOD,
51
+ RULE_UNRESOLVED_TOPLEVEL,
52
+ RULE_WRONG_ARITY,
53
+ RULE_ARGUMENT_TYPE,
54
+ RULE_NIL_RECEIVER,
55
+ RULE_RAISE_NON_EXCEPTION,
56
+ RULE_DUMP_TYPE,
57
+ RULE_ASSERT_TYPE,
58
+ RULE_ALWAYS_RAISES,
59
+ RULE_UNREACHABLE_BRANCH,
60
+ RULE_DEAD_ASSIGNMENT,
61
+ RULE_ALWAYS_TRUTHY_CONDITION,
62
+ RULE_UNREACHABLE_CLAUSE,
63
+ RULE_DUPLICATE_HASH_KEY,
64
+ RULE_RETURN_IN_ENSURE,
65
+ RULE_SHADOWED_RESCUE_CLAUSE,
66
+ RULE_RETURN_TYPE,
67
+ RULE_VISIBILITY_MISMATCH,
68
+ RULE_OVERRIDE_VISIBILITY_REDUCED,
69
+ RULE_OVERRIDE_RETURN_WIDENED,
70
+ RULE_OVERRIDE_PARAM_NARROWED,
71
+ RULE_IVAR_WRITE_MISMATCH,
72
+ RULE_SUPPRESSION_UNKNOWN_RULE,
73
+ RULE_SUPPRESSION_EMPTY,
74
+ RULE_SUPPRESSION_UNKNOWN_MARKER,
75
+ RULE_VALUE_USE_VOID
76
+ ].freeze
77
+
78
+ # Backward-compat alias table (ADR-8 § "Backward compatibility"). Existing user code with
79
+ # `# rigor:disable undefined-method` / `disable: [undefined-method]` keeps working — the legacy
80
+ # unprefixed identifiers map to their canonical `family.rule-name` form here. Removing the aliases is a
81
+ # future ADR once user code has migrated; until then, both spellings resolve identically.
82
+ LEGACY_RULE_ALIASES = {
83
+ "undefined-method" => RULE_UNDEFINED_METHOD,
84
+ "self-undefined-method" => RULE_SELF_UNDEFINED_METHOD,
85
+ "wrong-arity" => RULE_WRONG_ARITY,
86
+ "argument-type-mismatch" => RULE_ARGUMENT_TYPE,
87
+ "possible-nil-receiver" => RULE_NIL_RECEIVER,
88
+ "raise-non-exception" => RULE_RAISE_NON_EXCEPTION,
89
+ "dump-type" => RULE_DUMP_TYPE,
90
+ "assert-type" => RULE_ASSERT_TYPE,
91
+ "always-raises" => RULE_ALWAYS_RAISES,
92
+ "unreachable-branch" => RULE_UNREACHABLE_BRANCH,
93
+ "method-visibility-mismatch" => RULE_VISIBILITY_MISMATCH,
94
+ "ivar-write-mismatch" => RULE_IVAR_WRITE_MISMATCH,
95
+ "dead-assignment" => RULE_DEAD_ASSIGNMENT,
96
+ "always-truthy-condition" => RULE_ALWAYS_TRUTHY_CONDITION,
97
+ "unreachable-clause" => RULE_UNREACHABLE_CLAUSE,
98
+ "duplicate-hash-key" => RULE_DUPLICATE_HASH_KEY,
99
+ "return-in-ensure" => RULE_RETURN_IN_ENSURE,
100
+ "shadowed-rescue-clause" => RULE_SHADOWED_RESCUE_CLAUSE
101
+ }.freeze
102
+
103
+ # Family wildcard — a `<family>` token in a suppression comment or `disable:` list disables every rule
104
+ # whose canonical id starts with `<family>.`. Per ADR-8 § "1".
105
+ RULE_FAMILIES = %w[call flow assert dump def suppression static].freeze
106
+
107
+ # Families of diagnostics the engine emits OUTSIDE the CheckRules catalogue (aggregator-level and
108
+ # reporter-level diagnostics such as `rbs_extended.unsatisfied-conformance`,
109
+ # `dynamic.dependency-source.*`, `rbs.coverage.*`, `pre-eval.parse-error`), plus the `plugin.` prefix
110
+ # reserved for plugin-produced identifiers. `suppression.unknown-rule` treats a dotted token whose
111
+ # first segment appears here as KNOWN and stays silent: these ids are legitimate suppression /
112
+ # `severity_overrides:` vocabulary the light rule-id table cannot enumerate (plugins load dynamically;
113
+ # aggregator ids live in the engine-heavy runner), so under-warning is the FP-safe direction.
114
+ NON_CHECK_DIAGNOSTIC_FAMILIES = %w[rbs_extended dynamic rbs pre-eval plugin].freeze
115
+
116
+ # Bare (dot-less) diagnostic ids the engine emits outside the catalogue (see the `rule:` literals in
117
+ # `Analysis::Runner` / `Runner::DiagnosticAggregator`). A token equal to one of these is treated as
118
+ # known by `suppression.unknown-rule` even though it carries no family prefix; extend the list when
119
+ # the runner grows a new bare id.
120
+ NON_CHECK_DIAGNOSTIC_IDS = %w[
121
+ configuration-error load-error pool-degraded runtime-error source-rbs-synthesis-failed
122
+ ].freeze
123
+ end
124
+ end
125
+ end
@@ -3,6 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "../../source/constant_path"
6
+ require_relative "../../source/node_children"
6
7
 
7
8
  module Rigor
8
9
  module Analysis
@@ -145,7 +146,7 @@ module Rigor
145
146
 
146
147
  dispatch(node, hooks, context)
147
148
  child_context = descend(node, context)
148
- node.compact_child_nodes.each { |child| walk(child, hooks, child_context) }
149
+ node.rigor_each_child { |child| walk(child, hooks, child_context) }
149
150
  end
150
151
 
151
152
  def dispatch(node, hooks, context)
@@ -3,6 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  require_relative "../../source/constant_path"
6
+ require_relative "../../source/node_children"
6
7
 
7
8
  module Rigor
8
9
  module Analysis
@@ -52,7 +53,7 @@ module Rigor
52
53
  names << child_prefix.join("::") if name && class_surface_open?(node)
53
54
  walk(node.body, child_prefix, names) if node.body
54
55
  else
55
- node.compact_child_nodes.each { |child| walk(child, prefix, names) }
56
+ node.rigor_each_child { |child| walk(child, prefix, names) }
56
57
  end
57
58
  end
58
59
 
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../../reflection"
6
+ require_relative "../../source/constant_path"
7
+ require_relative "../../source/node_children"
8
+
9
+ module Rigor
10
+ module Analysis
11
+ module CheckRules
12
+ # `flow.shadowed-rescue-clause` — a `rescue` clause that can never run because an EARLIER clause of the
13
+ # same `begin` / `def` rescue chain already catches a superclass (or the same class) of every exception
14
+ # class the later clause names (`rescue StandardError => e … rescue ArgumentError => e` — the
15
+ # ArgumentError arm is dead; a bare `rescue` counts as `StandardError` on either side). Modeled on the
16
+ # shadowed-arm half of PHPStan's `CatchWithUnthrownExceptionRule`; the never-thrown half is deliberately
17
+ # out of scope (unprovable in Ruby).
18
+ #
19
+ # The false-positive envelope is ancestry-certainty, not flow analysis — the rule is purely syntactic
20
+ # plus class-hierarchy lookups, so it fires ONLY when:
21
+ #
22
+ # - every exception reference in BOTH the earlier and the later clause is a constant path that resolves
23
+ # (against the clause's lexical namespace) to a CLASS with known ancestry — an RBS / registry core
24
+ # class, or a project class whose `class Foo < Bar` superclass chain is discovered. Any unresolved
25
+ # constant, dynamic expression (`rescue klass_var`), or splat (`rescue *ERRORS`) makes its clause
26
+ # opaque: an opaque earlier clause contributes no coverage, an opaque later clause never fires.
27
+ # - no reference resolves to a MODULE (the `rescue MyGem::Error` module-tag mixin pattern — module
28
+ # `===` semantics are custom, so any clause naming one stays out of every comparison). A
29
+ # project-discovered constant is certified as a class only via the `class Foo < Bar` superclass
30
+ # table; a plain `class Foo` / `module Foo` declaration is indistinguishable in the discovery table
31
+ # and stays silent.
32
+ # - a multi-class arm (`rescue A, B => e`) is shadowed only when EVERY class it names is covered by
33
+ # some earlier comparable clause — one diagnostic per clause, naming the covering earlier clause(s).
34
+ #
35
+ # A later clause naming a SUPERCLASS of an earlier one is the normal narrow-to-wide rescue order and
36
+ # never fires. `retry` in an earlier arm does not un-shadow (the re-raise re-enters from the top).
37
+ # Nested `begin` / `rescue` inside a rescue body compares only clauses of its OWN `BeginNode` — the
38
+ # walk dispatches per `BeginNode` and never crosses into another chain.
39
+ class ShadowedRescueCollector
40
+ # One shadowed clause. `clause` is the dead `Prism::RescueNode`; `clause_source` its rendered
41
+ # `rescue A, B` condition list (`"rescue"` for a bare rescue); `earlier_sources` /
42
+ # `earlier_lines` name the earlier clause(s) whose classes cover it.
43
+ Result = Data.define(:clause, :clause_source, :earlier_sources, :earlier_lines)
44
+
45
+ NODE_CLASSES = [Prism::BeginNode].freeze
46
+
47
+ # Cycle / depth bound for the project `class Foo < Bar` superclass-chain walk.
48
+ SUPERCLASS_WALK_LIMIT = 32
49
+
50
+ # The implicit class of a bare `rescue` / `rescue => e`.
51
+ IMPLICIT_RESCUE_CLASS = "StandardError"
52
+
53
+ def initialize(scope_index)
54
+ @scope_index = scope_index
55
+ @results = []
56
+ end
57
+
58
+ # Legacy single-collector walk — the oracle the shadow harness (`RIGOR_SHADOW_RULE_WALK=1`) compares
59
+ # the shared {RuleWalk} traversal against. Tracks the lexical class / module prefix itself, exactly
60
+ # as {RuleWalk} threads `qualified_prefix`.
61
+ # @return [Array<Result>] one entry per provably-shadowed rescue clause.
62
+ def collect(root)
63
+ walk(root, [])
64
+ @results.freeze
65
+ end
66
+
67
+ # {RuleWalk} entry point. `context.qualified_prefix` carries the enclosing class / module names the
68
+ # exception constants resolve against.
69
+ def visit(node, context = nil)
70
+ prefix = context.respond_to?(:qualified_prefix) ? context.qualified_prefix : []
71
+ collect_begin(node, prefix)
72
+ end
73
+
74
+ def results
75
+ @results.freeze
76
+ end
77
+
78
+ private
79
+
80
+ def walk(node, prefix)
81
+ return unless node.is_a?(Prism::Node)
82
+
83
+ collect_begin(node, prefix) if node.is_a?(Prism::BeginNode)
84
+ child_prefix = extend_prefix(node, prefix)
85
+ node.rigor_each_child { |child| walk(child, child_prefix) }
86
+ end
87
+
88
+ def extend_prefix(node, prefix)
89
+ return prefix unless node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
90
+
91
+ name = Source::ConstantPath.qualified_name(node.constant_path)
92
+ name ? prefix + [name] : prefix
93
+ end
94
+
95
+ def collect_begin(node, prefix)
96
+ clauses = rescue_chain(node.rescue_clause)
97
+ return if clauses.size < 2
98
+
99
+ scope = scope_for(node)
100
+ return if scope.nil?
101
+
102
+ infos = clauses.map { |clause| clause_info(clause, prefix, scope) }
103
+ infos.each_with_index do |info, index|
104
+ next if index.zero? || info.nil?
105
+
106
+ check_shadowed(info, infos.first(index), scope)
107
+ end
108
+ end
109
+
110
+ # The ordered `rescue` clauses of one `begin` node (the `subsequent` chain).
111
+ def rescue_chain(clause)
112
+ chain = []
113
+ while clause
114
+ chain << clause
115
+ clause = clause.subsequent
116
+ end
117
+ chain
118
+ end
119
+
120
+ # A `Scope` for the environment / discovery lookups. Every scope of the file shares the discovery
121
+ # index and environment, so any scope recorded on the begin node's neighbourhood serves; nil (no
122
+ # scope recorded) keeps the whole chain silent.
123
+ def scope_for(node)
124
+ @scope_index[node] || @scope_index[node.statements] || @scope_index[node.rescue_clause]
125
+ end
126
+
127
+ # Resolves one clause to `{clause:, names: [String, …]}` where `names` are the certified exception
128
+ # CLASS names it rescues, or nil when the clause is opaque (any entry unresolved / dynamic / splat /
129
+ # module — the comparisons involving it stay silent). A bare `rescue` names the implicit
130
+ # `StandardError`.
131
+ def clause_info(clause, prefix, scope)
132
+ exceptions = clause.exceptions
133
+ if exceptions.empty?
134
+ return unless certified_class?(IMPLICIT_RESCUE_CLASS, scope)
135
+
136
+ return { clause: clause, names: [IMPLICIT_RESCUE_CLASS] }
137
+ end
138
+
139
+ names = exceptions.map { |exc| certified_exception_class(exc, prefix, scope) }
140
+ return if names.any?(&:nil?)
141
+
142
+ { clause: clause, names: names }
143
+ end
144
+
145
+ # The resolved, certified class name for one exception reference, or nil (opaque). Only constant
146
+ # reads / constant paths participate; the name must resolve lexically and certify as a class with
147
+ # known ancestry.
148
+ def certified_exception_class(node, prefix, scope)
149
+ name = resolve_constant(node, prefix, scope)
150
+ return nil if name.nil?
151
+
152
+ certified_class?(name, scope) ? name : nil
153
+ end
154
+
155
+ # Lexical constant resolution: innermost enclosing namespace first, then outward, then top-level —
156
+ # first candidate any knowledge source (project discovery or RBS / registry) recognises wins. An
157
+ # absolute path (`::Foo`) skips the lexical ladder. Returns nil for a non-constant node or a path
158
+ # with a dynamic base.
159
+ def resolve_constant(node, prefix, scope)
160
+ name = Source::ConstantPath.qualified_name_or_nil(node)
161
+ return nil if name.nil?
162
+
163
+ return name if absolute_path?(node)
164
+
165
+ candidates = []
166
+ prefix.size.downto(1) { |i| candidates << (prefix.first(i) + [name]).join("::") }
167
+ candidates << name
168
+ candidates.find { |candidate| known_name?(candidate, scope) }
169
+ end
170
+
171
+ def absolute_path?(node)
172
+ return false unless node.is_a?(Prism::ConstantPathNode)
173
+
174
+ parent = node.parent
175
+ return true if parent.nil? # `::Foo`
176
+
177
+ parent.is_a?(Prism::ConstantPathNode) && absolute_path?(parent)
178
+ end
179
+
180
+ # Presence check used to pick the lexical candidate — deliberately broader than certification, so a
181
+ # name that resolves to a project module is FOUND here and then rejected by {#certified_class?}
182
+ # (rather than mis-resolving outward to a same-named core class).
183
+ def known_name?(name, scope)
184
+ scope.discovered_superclasses.key?(name) ||
185
+ scope.discovered_classes.key?(name) ||
186
+ scope.environment.class_known?(name)
187
+ end
188
+
189
+ # A name is a certified exception-comparable class when it is either a project class with a
190
+ # discovered `class Foo < Bar` superclass entry, or an RBS / registry class that is not a module.
191
+ # A project-discovered name WITHOUT a superclass entry cannot be told apart from a module (the
192
+ # discovery table records both), so it stays uncertified — conservative, per the FP discipline.
193
+ def certified_class?(name, scope)
194
+ return true if scope.discovered_superclasses.key?(name)
195
+ return false if scope.discovered_classes.key?(name)
196
+
197
+ environment = scope.environment
198
+ environment.class_known?(name) && !environment.rbs_module?(name)
199
+ end
200
+
201
+ # Fires when EVERY class the later clause names is covered by some earlier comparable clause.
202
+ def check_shadowed(info, earlier_infos, scope)
203
+ comparable_earlier = earlier_infos.compact
204
+ return if comparable_earlier.empty?
205
+
206
+ covering = info[:names].map do |name|
207
+ comparable_earlier.find { |earlier| earlier[:names].any? { |e| covered_by?(name, e, scope) } }
208
+ end
209
+ return if covering.any?(&:nil?)
210
+
211
+ clauses = covering.uniq
212
+ @results << Result.new(
213
+ clause: info[:clause],
214
+ clause_source: clause_source(info[:clause]),
215
+ earlier_sources: clauses.map { |c| clause_source(c[:clause]) },
216
+ earlier_lines: clauses.map { |c| c[:clause].location.start_line }
217
+ )
218
+ end
219
+
220
+ def clause_source(clause)
221
+ exceptions = clause.exceptions
222
+ return "rescue" if exceptions.empty?
223
+
224
+ "rescue #{exceptions.map(&:slice).join(', ')}"
225
+ end
226
+
227
+ # Is `later` the same class as, or a subclass of, `earlier`? RBS / registry ancestry answers first;
228
+ # `:unknown` (a project-defined class on either side) falls through to the discovered
229
+ # `class Foo < Bar` superclass-chain walk. `:superclass` / `:disjoint` are definitive "no".
230
+ def covered_by?(later, earlier, scope)
231
+ return true if later == earlier
232
+
233
+ case Reflection.class_ordering(later, earlier, scope: scope)
234
+ when :equal, :subclass then true
235
+ when :unknown then project_chain_covered?(later, earlier, scope)
236
+ else false
237
+ end
238
+ end
239
+
240
+ # Walks `later`'s discovered superclass chain (each parent recorded AS WRITTEN at its
241
+ # `class Foo < Bar` site, resolved against the child's namespace) looking for `earlier` — directly,
242
+ # or through an RBS-known ancestor that orders at-or-below `earlier`. Cycle-guarded and depth-capped;
243
+ # a chain that leaves both knowledge sources stays silent.
244
+ def project_chain_covered?(later, earlier, scope)
245
+ supers = scope.discovered_superclasses
246
+ current = later
247
+ seen = {}
248
+ SUPERCLASS_WALK_LIMIT.times do
249
+ parent = supers[current.to_s]
250
+ return false if parent.nil?
251
+
252
+ resolved = resolve_parent(current, parent.to_s, scope)
253
+ return false if seen[resolved]
254
+
255
+ seen[resolved] = true
256
+ return true if resolved == earlier
257
+
258
+ case Reflection.class_ordering(resolved, earlier, scope: scope)
259
+ when :equal, :subclass then return true
260
+ when :unknown then current = resolved
261
+ else return false
262
+ end
263
+ end
264
+ false
265
+ end
266
+
267
+ # Resolves a superclass name as written (`Error`) against the child's namespace (`Mail::POP3` →
268
+ # `Mail::Error`), longest prefix first; falls back to the as-written name (which RBS may know, e.g.
269
+ # `StandardError`).
270
+ def resolve_parent(child, parent, scope)
271
+ segments = child.to_s.split("::")[0...-1]
272
+ until segments.empty?
273
+ candidate = "#{segments.join('::')}::#{parent}"
274
+ return candidate if known_name?(candidate, scope)
275
+
276
+ segments.pop
277
+ end
278
+ parent
279
+ end
280
+ end
281
+ end
282
+ end
283
+ end
@@ -2,6 +2,9 @@
2
2
 
3
3
  require "prism"
4
4
 
5
+ require_relative "../../source/node_children"
6
+ require_relative "inferred_param_guard"
7
+
5
8
  module Rigor
6
9
  module Analysis
7
10
  module CheckRules
@@ -85,7 +88,7 @@ module Rigor
85
88
  visit(node) if case_like?(node) && !in_loop_or_block
86
89
 
87
90
  child_in_loop_or_block = in_loop_or_block || enters_loop_or_block?(node)
88
- node.compact_child_nodes.each { |child| walk(child, in_loop_or_block: child_in_loop_or_block) }
91
+ node.rigor_each_child { |child| walk(child, in_loop_or_block: child_in_loop_or_block) }
89
92
  end
90
93
 
91
94
  # `case/when` is a `CaseNode`; `case/in` is a `CaseMatchNode`. Both carry `predicate` + `conditions`
@@ -102,6 +105,12 @@ module Rigor
102
105
  subject = node.predicate
103
106
  return unless subject.is_a?(Prism::LocalVariableReadNode)
104
107
 
108
+ # ADR-67 WD6b — a `case` subject that is an inferred parameter narrows to `Bot` in the non-matching
109
+ # clauses only because its seeded type is a lower bound; a wider real caller could match them. Decline
110
+ # the whole case so no clause fires unreachable.
111
+ scope = @scope_index[node]
112
+ return if scope && InferredParamGuard.rooted?(subject, scope)
113
+
105
114
  entry_type = entry_subject_type(node, subject.name)
106
115
  # Only meaningful for a concrete subject type: a `Dynamic` subject can never be proven disjoint
107
116
  # (the gradual guarantee), and an already-`Bot` subject is dead code, not a clause-ordering error.