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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fb4015697adf7cbc9d65b2ca6ff954c8cac5f784ffb9616153d4af8d2505ccc
4
- data.tar.gz: 52ee5acb7d233c0e86c8cdca45151c5f745b4c13d7155fcb15fafdaaed1d6dc9
3
+ metadata.gz: 02ca1aeff8df7e80afcbc2f1fb03a808466dd307e1a935c68c4af7055dfa975b
4
+ data.tar.gz: 2a7b829348f75fe48bf3741a691fcfb1aa67b863675384feffe91a6fabb6ee6b
5
5
  SHA512:
6
- metadata.gz: 4f4b64afe3dbca26f6224762a2f1a3dd0bd4cade2ea9a61a30e2fba28ed29d3bb6d68bdfa73fb450536e5c7fef45e07a10a6fd0cec693ad228a828ff30905f8e
7
- data.tar.gz: 4b5faa0d8c7295067f5a0f3d335c1800f22659e47b0428942405b07c1d38649163090f3a461e942a82aecaab6172e774ef88ca44f9a569b1392cdeee0be04086
6
+ metadata.gz: f1dfc043a607024bc46987acf0fc925b7926294a6e46b668793bc03fa88e0b1066bea51db5e80a038483b602ad59e788269ac8baa5615a1694cbc8afd90614c4
7
+ data.tar.gz: 894eccadf5220d1c9b16d09e07f799e1104265a1e3cbedb9a738330441d931a77eb24314e63768a24da2e4d179221c34f297c32c82e905dcfce749b36264c94c
data/README.md CHANGED
@@ -265,12 +265,14 @@ In-source suppression: `# rigor:disable <rule>` silences a single line;
265
265
 
266
266
  ## Status
267
267
 
268
- Current released version: **`v0.1.15`** (2026-05-29). The analyzer is
268
+ Current released version: **`v0.1.18`** (2026-06-11). The analyzer is
269
269
  usable on real Ruby code today; the rule catalogue is deliberately
270
270
  conservative — Rigor's stance is to surface zero false positives while
271
271
  the inference surface stabilises. The `0.1.x` preview line has been
272
272
  hardened against real OSS Rails codebases (Mastodon / Redmine / GitLab
273
- FOSS); `v0.2.0` will open the first evaluation release.
273
+ FOSS) and wired for CI — `rigor check` emits SARIF / GitHub / GitLab /
274
+ Checkstyle / JUnit / TeamCity output and auto-detects the CI environment;
275
+ `v0.2.0` will open the first evaluation release.
274
276
 
275
277
  Release history: [`CHANGELOG.md`](CHANGELOG.md). Forward-looking
276
278
  commitments: [Roadmap](https://rigor.typedduck.fail/reference/roadmap/).
@@ -56,6 +56,10 @@ module Rigor
56
56
 
57
57
  Result = Data.define(:node, :polarity)
58
58
 
59
+ # ADR-53 Track B — the node classes the shared {RuleWalk}
60
+ # dispatches to this collector (outside loop / block bodies).
61
+ NODE_CLASSES = [Prism::IfNode, Prism::UnlessNode].freeze
62
+
59
63
  # @return [Array<Result>] one entry per qualifying
60
64
  # predicate. Empty when the tree carries no firing
61
65
  # predicates.
@@ -64,17 +68,30 @@ module Rigor
64
68
  @results = []
65
69
  end
66
70
 
71
+ # Legacy single-collector walk — kept as the oracle the
72
+ # ADR-53 Track B equivalence harness compares {RuleWalk}
73
+ # against; deleted when Track B completes.
67
74
  def collect(root)
68
75
  walk(root, in_loop_or_block: false)
69
76
  @results.freeze
70
77
  end
71
78
 
79
+ # {RuleWalk} entry point: the per-node logic of the legacy walk,
80
+ # invoked under the same traversal contract.
81
+ def visit(node)
82
+ collect_predicate(node)
83
+ end
84
+
85
+ def results
86
+ @results.freeze
87
+ end
88
+
72
89
  private
73
90
 
74
91
  def walk(node, in_loop_or_block:)
75
92
  return unless node.is_a?(Prism::Node)
76
93
 
77
- collect_predicate(node) if conditional_node?(node) && !in_loop_or_block
94
+ visit(node) if conditional_node?(node) && !in_loop_or_block
78
95
 
79
96
  child_in_loop_or_block = in_loop_or_block || enters_loop_or_block?(node)
80
97
  node.compact_child_nodes.each { |child| walk(child, in_loop_or_block: child_in_loop_or_block) }
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ module CheckRules
8
+ # ADR-53 Track B — the engine-owned AST walk that hosts CheckRules'
9
+ # per-node collectors, so a file is traversed once for all of them
10
+ # instead of once per collector (the ADR-52 WD4 model applied to
11
+ # the built-in rules; this walk and {Plugin::NodeRuleWalk} converge
12
+ # in slice B4).
13
+ #
14
+ # Slice B2 hosts the two flow collectors
15
+ # ({AlwaysTruthyConditionCollector} / {UnreachableClauseCollector}),
16
+ # whose hand-rolled walks shared this exact traversal contract:
17
+ # visit-before-descend DFS over `compact_child_nodes`, with
18
+ # collection suppressed anywhere inside a loop / block body
19
+ # (mutation tracking through those is incomplete — each collector's
20
+ # envelope documents why). A collector joins the walk by declaring
21
+ # `NODE_CLASSES` and exposing `#visit(node)` with its per-node logic
22
+ # transplanted verbatim; a collector with a different traversal
23
+ # contract (class-nesting prefix, def-scoped scanning) stays on its
24
+ # own walk until a later slice threads the context it needs.
25
+ #
26
+ # Equivalence is load-bearing: every hosted collector keeps its
27
+ # legacy single-collector `#collect` walk as the oracle, compared
28
+ # against this walk by the permanent `rule_walk_equivalence_spec`
29
+ # and, over whole corpora, by `RIGOR_SHADOW_RULE_WALK=1`
30
+ # (see `CheckRules.flow_collector_results`).
31
+ module RuleWalk
32
+ LOOP_OR_BLOCK_NODE_CLASSES = [
33
+ Prism::WhileNode, Prism::UntilNode, Prism::ForNode, Prism::BlockNode
34
+ ].freeze
35
+
36
+ class << self
37
+ # Walks `root` once, dispatching each visited node to every
38
+ # collector whose `NODE_CLASSES` covers the node's class.
39
+ # Dispatch keys on the exact node class — Prism node classes
40
+ # are leaves, so this matches the collectors' `is_a?` gates.
41
+ def run(root, collectors)
42
+ hooks = {}
43
+ collectors.each do |collector|
44
+ collector.class::NODE_CLASSES.each do |node_class|
45
+ (hooks[node_class] ||= []) << collector
46
+ end
47
+ end
48
+ walk(root, hooks, in_loop_or_block: false)
49
+ collectors
50
+ end
51
+
52
+ private
53
+
54
+ def walk(node, hooks, in_loop_or_block:)
55
+ return unless node.is_a?(Prism::Node)
56
+
57
+ hooks[node.class]&.each { |collector| collector.visit(node) } unless in_loop_or_block
58
+
59
+ child_in_loop_or_block =
60
+ in_loop_or_block || LOOP_OR_BLOCK_NODE_CLASSES.any? { |klass| node.is_a?(klass) }
61
+ node.compact_child_nodes.each { |child| walk(child, hooks, in_loop_or_block: child_in_loop_or_block) }
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ module CheckRules
8
+ # ADR-24 slice 4 — read-side companion to `call.self-undefined-method`.
9
+ # Walks a file's parse tree once and collects the qualified names of
10
+ # declarations that are NOT confidently closed for self-call
11
+ # undefined-method analysis, so the rule can exclude them:
12
+ #
13
+ # - **Modules** — a `module M` is a mixin contract; its methods may be
14
+ # provided by includers (template-method pattern), so an unresolved
15
+ # self-call inside it is not provably a typo.
16
+ # - **Classes with a dynamic `attr_*` accessor** — `attr_reader(*NAMES)`
17
+ # / `attr_accessor(:a, SOME_CONST)` synthesizes readers whose names
18
+ # are not statically decidable, so the class's method surface cannot
19
+ # be enumerated from source.
20
+ #
21
+ # The enclosing declaration of a self-call is always in the same file
22
+ # as the call (even a reopened class restates `class Foo`), so a
23
+ # per-file AST scan suffices — no cross-file plumbing. Names are built
24
+ # to match the engine's qualified `self` type (`Module.nesting`-style
25
+ # `A::B::C`).
26
+ class SelfClosednessScanner
27
+ ATTR_MACROS = %i[attr_reader attr_accessor attr_writer].freeze
28
+ private_constant :ATTR_MACROS
29
+
30
+ def initialize(root)
31
+ @root = root
32
+ end
33
+
34
+ # @return [Set<String>] qualified names to exclude from the rule.
35
+ def open_class_names
36
+ names = Set.new
37
+ walk(@root, [], names)
38
+ names
39
+ end
40
+
41
+ private
42
+
43
+ def walk(node, prefix, names)
44
+ return unless node.is_a?(Prism::Node)
45
+
46
+ case node
47
+ when Prism::ModuleNode
48
+ name = constant_path_name(node.constant_path)
49
+ child_prefix = name ? prefix + [name] : prefix
50
+ names << child_prefix.join("::") if name
51
+ walk(node.body, child_prefix, names) if node.body
52
+ when Prism::ClassNode
53
+ name = constant_path_name(node.constant_path)
54
+ child_prefix = name ? prefix + [name] : prefix
55
+ names << child_prefix.join("::") if name && dynamic_attr_class?(node)
56
+ walk(node.body, child_prefix, names) if node.body
57
+ else
58
+ node.compact_child_nodes.each { |child| walk(child, prefix, names) }
59
+ end
60
+ end
61
+
62
+ # True when the class body directly invokes an `attr_*` macro with a
63
+ # non-literal argument (a splat, a constant, a method call) — the
64
+ # synthesized accessor names are then not statically knowable.
65
+ def dynamic_attr_class?(class_node)
66
+ body = class_node.body
67
+ return false unless body.is_a?(Prism::StatementsNode)
68
+
69
+ body.body.any? do |stmt|
70
+ stmt.is_a?(Prism::CallNode) &&
71
+ stmt.receiver.nil? &&
72
+ ATTR_MACROS.include?(stmt.name) &&
73
+ dynamic_attr_arguments?(stmt)
74
+ end
75
+ end
76
+
77
+ def dynamic_attr_arguments?(call_node)
78
+ args = call_node.arguments&.arguments
79
+ return false if args.nil? || args.empty?
80
+
81
+ args.any? { |arg| !arg.is_a?(Prism::SymbolNode) && !arg.is_a?(Prism::StringNode) }
82
+ end
83
+
84
+ # Renders a class/module path node to its source-qualified name
85
+ # (`Foo`, `Foo::Bar`, leading `::` stripped); nil for a dynamic
86
+ # constant path the scanner cannot name statically.
87
+ def constant_path_name(node)
88
+ case node
89
+ when Prism::ConstantReadNode
90
+ node.name.to_s
91
+ when Prism::ConstantPathNode
92
+ parent = node.parent ? constant_path_name(node.parent) : nil
93
+ child = node.name.to_s
94
+ node.parent.nil? ? child : (parent && "#{parent}::#{child}")
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ module CheckRules
8
+ # ADR-47 — collects `case`/`when` clauses proven unreachable. The flow
9
+ # engine already narrows the case subject across `when` branches
10
+ # (`Narrowing.case_when_scopes`), and the per-clause `body_scope` it
11
+ # produces is recorded in `scope_index` keyed on the `WhenNode` (the
12
+ # evaluator enters the clause under that scope). So a clause whose
13
+ # narrowed subject local is `Type::Bot` is one the engine's own
14
+ # narrowing proves no reaching value can match — a stale `when`,
15
+ # mis-ordered clauses, or a type that moved out from under a branch.
16
+ #
17
+ # WD1 (per-clause disjointness) — the narrow, high-value core. Fires
18
+ # ONLY on the safe shape:
19
+ #
20
+ # - the subject is a `case <local>` (a `LocalVariableReadNode`, the
21
+ # only shape `case_when_scopes` narrows — no narrowing ⇒ no firing),
22
+ # - every `when` condition is a class/module constant (`when String` /
23
+ # `when MyClass`), the shape the narrowing recognises — `when nil` /
24
+ # ranges / regexps / arbitrary expressions are out of scope here,
25
+ # - the subject's type at case entry is a concrete carrier, never
26
+ # `Type::Dynamic` (disjointness is never provable under gradual
27
+ # `Dynamic`, preserving the gradual guarantee) and never already
28
+ # `Type::Bot` (dead code, not a clause error),
29
+ # - and the clause's narrowed `body_scope` subject is `Type::Bot`.
30
+ #
31
+ # The false-positive envelope mirrors `flow.always-truthy-condition`:
32
+ # clauses inside loops / blocks are skipped (mutation tracking through
33
+ # those is incomplete), and the rule reads the engine's own narrowing
34
+ # rather than recomputing it, so the diagnostic and the body typing
35
+ # can never diverge.
36
+ class UnreachableClauseCollector
37
+ LOOP_OR_BLOCK_NODE_CLASSES = [
38
+ Prism::WhileNode, Prism::UntilNode, Prism::ForNode, Prism::BlockNode
39
+ ].freeze
40
+
41
+ # A trailing `else` whose body only re-raises / aborts is a
42
+ # deliberate defensive guard ("this should be unreachable — shout
43
+ # if it ever isn't"), NOT dead code the author wants removed.
44
+ # Flagging it would push the author to delete a safety net, which
45
+ # the false-positive discipline forbids. WD1's `when` shapes don't
46
+ # carry this idiom (a stale `when` is a real redundancy), so the
47
+ # skip is `else`-only.
48
+ DEFENSIVE_EXIT_CALLS = %i[raise fail throw abort exit].freeze
49
+
50
+ # @!attribute kind
51
+ # @return [Symbol] `:disjoint` (this clause's type is disjoint
52
+ # from the still-possible subject), `:prior_exhaustion` (an
53
+ # earlier clause already covered the subject), or
54
+ # `:exhausted_else` (every subject value is covered, so the
55
+ # `else` never runs).
56
+ Result = Data.define(:clause, :body, :subject_name, :condition_source, :kind, :keyword)
57
+
58
+ # ADR-53 Track B — the node classes the shared {RuleWalk}
59
+ # dispatches to this collector (outside loop / block bodies).
60
+ NODE_CLASSES = [Prism::CaseNode, Prism::CaseMatchNode].freeze
61
+
62
+ def initialize(scope_index)
63
+ @scope_index = scope_index
64
+ @results = []
65
+ end
66
+
67
+ # Legacy single-collector walk — kept as the oracle the
68
+ # ADR-53 Track B equivalence harness compares {RuleWalk}
69
+ # against; deleted when Track B completes.
70
+ # @return [Array<Result>] one entry per provably-dead `when` clause.
71
+ def collect(root)
72
+ walk(root, in_loop_or_block: false)
73
+ @results.freeze
74
+ end
75
+
76
+ # {RuleWalk} entry point: the per-node logic of the legacy walk,
77
+ # invoked under the same traversal contract.
78
+ def visit(node)
79
+ collect_case(node)
80
+ end
81
+
82
+ def results
83
+ @results.freeze
84
+ end
85
+
86
+ private
87
+
88
+ def walk(node, in_loop_or_block:)
89
+ return unless node.is_a?(Prism::Node)
90
+
91
+ visit(node) if case_like?(node) && !in_loop_or_block
92
+
93
+ child_in_loop_or_block = in_loop_or_block || enters_loop_or_block?(node)
94
+ node.compact_child_nodes.each { |child| walk(child, in_loop_or_block: child_in_loop_or_block) }
95
+ end
96
+
97
+ # `case/when` is a `CaseNode`; `case/in` is a `CaseMatchNode`. Both
98
+ # carry `predicate` + `conditions` + `else_clause` and the engine
99
+ # narrows both through `eval_case_when_branches`.
100
+ def case_like?(node)
101
+ node.is_a?(Prism::CaseNode) || node.is_a?(Prism::CaseMatchNode)
102
+ end
103
+
104
+ def enters_loop_or_block?(node)
105
+ LOOP_OR_BLOCK_NODE_CLASSES.any? { |klass| node.is_a?(klass) }
106
+ end
107
+
108
+ def collect_case(node)
109
+ subject = node.predicate
110
+ return unless subject.is_a?(Prism::LocalVariableReadNode)
111
+
112
+ entry_type = entry_subject_type(node, subject.name)
113
+ # Only meaningful for a concrete subject type: a `Dynamic` subject
114
+ # can never be proven disjoint (the gradual guarantee), and an
115
+ # already-`Bot` subject is dead code, not a clause-ordering error.
116
+ return if entry_type.nil? || entry_type.is_a?(Type::Bot) || entry_type.is_a?(Type::Dynamic)
117
+
118
+ keyword = node.is_a?(Prism::CaseMatchNode) ? "in" : "when"
119
+ node.conditions.each do |clause|
120
+ case clause
121
+ when Prism::WhenNode then collect_when(clause, subject.name)
122
+ when Prism::InNode then collect_in(clause, subject.name)
123
+ end
124
+ end
125
+ collect_else(node.else_clause, subject.name, keyword)
126
+ end
127
+
128
+ def entry_subject_type(case_node, subject_name)
129
+ scope = @scope_index[case_node]
130
+ scope&.local(subject_name)
131
+ end
132
+
133
+ def collect_when(clause, subject_name)
134
+ return if clause.statements.nil? # empty body → no useful location
135
+ return unless all_constant_conditions?(clause)
136
+
137
+ scope = @scope_index[clause]
138
+ return if scope.nil?
139
+ return unless scope.local(subject_name).is_a?(Type::Bot)
140
+
141
+ @results << Result.new(
142
+ clause: clause, body: clause.statements, subject_name: subject_name,
143
+ condition_source: clause.conditions.map(&:slice).join(", "),
144
+ kind: when_clause_kind(clause, subject_name), keyword: "when"
145
+ )
146
+ end
147
+
148
+ # A dead `when` is `:prior_exhaustion` when the subject was
149
+ # already narrowed to `bot` BEFORE this clause (an earlier clause
150
+ # covered it) — read from the entry scope the engine recorded on
151
+ # the clause's first condition node (ADR-47 WD2). Otherwise the
152
+ # subject was still concrete entering the clause and THIS clause's
153
+ # type is disjoint from it.
154
+ def when_clause_kind(clause, subject_name)
155
+ entry = @scope_index[clause.conditions.first]
156
+ return :prior_exhaustion if entry && entry.local(subject_name).is_a?(Type::Bot)
157
+
158
+ :disjoint
159
+ end
160
+
161
+ # ADR-47 WD3a — a dead `case/in` clause. The body scope reaches
162
+ # `bot` either because a bare class pattern (`in C` / `in C => x`)
163
+ # is disjoint from the still-possible subject (the engine narrows
164
+ # those soundly, like `when C`), or because an earlier clause
165
+ # already exhausted the subject (prior-exhaustion — true for ANY
166
+ # pattern downstream of a covering set). Non-bare patterns are not
167
+ # narrowed, so their body is `bot` ONLY under prior-exhaustion;
168
+ # they never produce a spurious `:disjoint`.
169
+ def collect_in(clause, subject_name)
170
+ return if clause.statements.nil?
171
+
172
+ scope = @scope_index[clause]
173
+ return if scope.nil?
174
+ return unless scope.local(subject_name).is_a?(Type::Bot)
175
+
176
+ @results << Result.new(
177
+ clause: clause, body: clause.statements, subject_name: subject_name,
178
+ condition_source: clause.pattern.slice,
179
+ kind: in_clause_kind(clause, subject_name), keyword: "in"
180
+ )
181
+ end
182
+
183
+ def in_clause_kind(clause, subject_name)
184
+ entry = @scope_index[clause.pattern]
185
+ return :prior_exhaustion if entry && entry.local(subject_name).is_a?(Type::Bot)
186
+
187
+ :disjoint
188
+ end
189
+
190
+ # The trailing `else` is dead when every subject value is covered
191
+ # by the `when` clauses — the final falsey scope (recorded on the
192
+ # `else` node) narrows the subject to `bot`. Defensive `else`
193
+ # bodies (a bare `raise` / `fail` / `throw`) are deliberate
194
+ # guards, not removable dead code, so they are skipped.
195
+ def collect_else(else_clause, subject_name, keyword)
196
+ return if else_clause.nil? || else_clause.statements.nil?
197
+ return if defensive_else?(else_clause)
198
+
199
+ scope = @scope_index[else_clause]
200
+ return if scope.nil?
201
+ return unless scope.local(subject_name).is_a?(Type::Bot)
202
+
203
+ @results << Result.new(
204
+ clause: else_clause, body: else_clause.statements, subject_name: subject_name,
205
+ condition_source: nil, kind: :exhausted_else, keyword: keyword
206
+ )
207
+ end
208
+
209
+ def defensive_else?(else_clause)
210
+ body = else_clause.statements.body
211
+ return false unless body.size == 1
212
+
213
+ stmt = body.first
214
+ stmt.is_a?(Prism::CallNode) && stmt.receiver.nil? &&
215
+ DEFENSIVE_EXIT_CALLS.include?(stmt.name)
216
+ end
217
+
218
+ def all_constant_conditions?(clause)
219
+ conditions = clause.conditions
220
+ !conditions.empty? &&
221
+ conditions.all? { |c| c.is_a?(Prism::ConstantReadNode) || c.is_a?(Prism::ConstantPathNode) }
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end