rigortype 0.1.18 → 0.1.19

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -224
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -3
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +169 -23
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  9. data/lib/rigor/analysis/check_rules.rb +266 -63
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +2 -1
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -1
  13. data/lib/rigor/analysis/runner.rb +58 -21
  14. data/lib/rigor/analysis/worker_session.rb +21 -11
  15. data/lib/rigor/bleeding_edge.rb +123 -0
  16. data/lib/rigor/cache/descriptor.rb +86 -8
  17. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  18. data/lib/rigor/cli/annotate_command.rb +100 -15
  19. data/lib/rigor/cli/check_command.rb +3 -0
  20. data/lib/rigor/cli/plugins_command.rb +2 -4
  21. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  22. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  23. data/lib/rigor/cli/triage_command.rb +6 -3
  24. data/lib/rigor/cli/triage_renderer.rb +15 -1
  25. data/lib/rigor/cli.rb +9 -1
  26. data/lib/rigor/configuration/severity_profile.rb +13 -1
  27. data/lib/rigor/configuration.rb +57 -1
  28. data/lib/rigor/environment/rbs_loader.rb +25 -0
  29. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  30. data/lib/rigor/inference/budget_trace.rb +29 -2
  31. data/lib/rigor/inference/expression_typer.rb +1052 -43
  32. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  33. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  34. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  35. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  36. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  37. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  38. data/lib/rigor/inference/method_dispatcher.rb +72 -1
  39. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  40. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  41. data/lib/rigor/inference/mutation_widening.rb +142 -0
  42. data/lib/rigor/inference/narrowing.rb +270 -37
  43. data/lib/rigor/inference/scope_indexer.rb +696 -25
  44. data/lib/rigor/inference/statement_evaluator.rb +963 -16
  45. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  46. data/lib/rigor/plugin/base.rb +235 -79
  47. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  48. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  49. data/lib/rigor/plugin/macro.rb +2 -3
  50. data/lib/rigor/plugin/manifest.rb +4 -24
  51. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  52. data/lib/rigor/plugin/registry.rb +12 -11
  53. data/lib/rigor/scope/discovery_index.rb +2 -0
  54. data/lib/rigor/scope.rb +132 -6
  55. data/lib/rigor/sig_gen/generator.rb +8 -0
  56. data/lib/rigor/triage/catalogue.rb +4 -19
  57. data/lib/rigor/triage.rb +69 -1
  58. data/lib/rigor/type/combinator.rb +29 -0
  59. data/lib/rigor/version.rb +1 -1
  60. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  61. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  62. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  63. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  64. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +20 -19
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +10 -8
  66. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  67. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  68. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  69. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  70. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  71. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  72. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +2 -13
  73. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  74. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  75. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  76. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +25 -0
  77. data/sig/rigor/analysis/fact_store.rbs +3 -0
  78. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  79. data/sig/rigor/plugin/base.rbs +5 -2
  80. data/sig/rigor/plugin/manifest.rbs +1 -2
  81. data/sig/rigor/scope.rbs +10 -1
  82. data/sig/rigor/type.rbs +1 -0
  83. data/sig/rigor.rbs +1 -1
  84. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  85. data/skills/rigor-plugin-author/SKILL.md +6 -4
  86. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  87. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  88. metadata +7 -2
  89. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -11,54 +11,200 @@ module Rigor
11
11
  # the built-in rules; this walk and {Plugin::NodeRuleWalk} converge
12
12
  # in slice B4).
13
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.
14
+ # The walk is a single visit-before-descend DFS over
15
+ # `compact_child_nodes`. It threads the union of the context the
16
+ # hosted collectors need — the per-collector hand-rolled walks each
17
+ # tracked some subset of it — in one immutable {Context} object,
18
+ # recomputed once per node during the single descent:
19
+ #
20
+ # - `in_loop_or_block` whether the node is inside a loop / block
21
+ # body (the two flow collectors suppress collection there because
22
+ # mutation tracking through those is incomplete).
23
+ # - `qualified_prefix` the enclosing class / module name stack
24
+ # ({IvarWriteCollector} builds its `class_name` from it).
25
+ # - `inside_def` — whether the node is lexically inside a `DefNode`
26
+ # ({IvarWriteCollector} collects only at a `def` that sits
27
+ # directly in a class / module body, never one nested in another
28
+ # `def`).
29
+ #
30
+ # A collector joins the walk by declaring `NODE_CLASSES` (the node
31
+ # classes it cares about — Prism node classes are leaves, so a
32
+ # class-equality dispatch matches the collectors' `is_a?` gates),
33
+ # optionally `RULE_WALK_GATES` (the {Context}-derived suppressions
34
+ # the walk applies before calling it), and `#visit(node, context)`
35
+ # with its per-node logic transplanted verbatim. Only the
36
+ # *traversal* merges here; each collector's gather / filter logic is
37
+ # unchanged from its legacy `#collect` walk (ADR-53 WD4).
25
38
  #
26
39
  # Equivalence is load-bearing: every hosted collector keeps its
27
40
  # legacy single-collector `#collect` walk as the oracle, compared
28
41
  # against this walk by the permanent `rule_walk_equivalence_spec`
29
42
  # and, over whole corpora, by `RIGOR_SHADOW_RULE_WALK=1`
30
- # (see `CheckRules.flow_collector_results`).
43
+ # (see `CheckRules.run_node_collectors`).
31
44
  module RuleWalk
32
45
  LOOP_OR_BLOCK_NODE_CLASSES = [
33
46
  Prism::WhileNode, Prism::UntilNode, Prism::ForNode, Prism::BlockNode
34
47
  ].freeze
35
48
 
49
+ CLASS_OR_MODULE_NODE_CLASSES = [Prism::ClassNode, Prism::ModuleNode].freeze
50
+
51
+ # The immutable per-node descent context. Recomputed for each
52
+ # child from its parent's context as the walk descends; collectors
53
+ # read only the fields they declared a need for.
54
+ Context = Data.define(:in_loop_or_block, :qualified_prefix, :inside_def) do
55
+ def self.root
56
+ new(in_loop_or_block: false, qualified_prefix: [], inside_def: false)
57
+ end
58
+ end
59
+
60
+ # Suppression gates a collector may declare via `RULE_WALK_GATES`.
61
+ # The walk skips a collector's `#visit` for a node when any of the
62
+ # collector's declared gates holds in that node's context — exactly
63
+ # reproducing the per-collector legacy walk's traversal suppression.
64
+ # Each gate names the {Context} predicate method that, when true,
65
+ # suppresses the visit:
66
+ #
67
+ # - `:loop_or_block` (`in_loop_or_block`) — inside a loop / block
68
+ # body, the two flow collectors' envelope.
69
+ # - `:inside_def` (`inside_def`) — lexically inside a `DefNode`;
70
+ # IvarWrite collects only at a `def` that sits directly in a
71
+ # class / module body (its legacy walk `return`s at the first
72
+ # `def` it meets, so a nested def is never reached).
73
+ GATE_CONTEXT_PREDICATES = {
74
+ loop_or_block: :in_loop_or_block,
75
+ inside_def: :inside_def
76
+ }.freeze
77
+
78
+ # A per-node driver over a fixed collector set: holds the compiled
79
+ # `node_class => [[collector, gates], …]` hook table and applies
80
+ # the dispatch + context-descent that {RuleWalk.run} performs, but
81
+ # one node at a time. This lets a foreign traversal (the converged
82
+ # {Plugin::NodeRuleWalk}, ADR-53 B4) drive the built-in collectors
83
+ # from inside its own single walk: it calls {#visit} on each node
84
+ # with that node's {Context}, and derives a child's context with
85
+ # {#descend}. The dispatch / gate / descend logic is identical to
86
+ # the standalone {RuleWalk.run} walk — only the traversal driving
87
+ # it differs, keeping diagnostics byte-identical (ADR-53 WD4).
88
+ class CollectorDriver
89
+ def initialize(collectors)
90
+ @hooks = RuleWalk.build_hooks(collectors)
91
+ freeze
92
+ end
93
+
94
+ # Dispatch `node` (under its own `context`) to every matching,
95
+ # un-gated collector. Mirrors {RuleWalk.dispatch}.
96
+ def visit(node, context)
97
+ matched = @hooks[node.class]
98
+ return if matched.nil?
99
+
100
+ matched.each do |collector, gates|
101
+ next if gates.any? { |predicate| context.public_send(predicate) }
102
+
103
+ collector.visit(node, context)
104
+ end
105
+ end
106
+
107
+ # The context the children of `node` descend under. Mirrors
108
+ # {RuleWalk.descend}.
109
+ def descend(node, context)
110
+ RuleWalk.descend(node, context)
111
+ end
112
+ end
113
+
36
114
  class << self
37
115
  # 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.
116
+ # collector whose `NODE_CLASSES` covers the node's class and
117
+ # whose declared `RULE_WALK_GATES` do not suppress it in that
118
+ # node's context.
41
119
  def run(root, collectors)
120
+ hooks = build_hooks(collectors)
121
+ walk(root, hooks, Context.root)
122
+ collectors
123
+ end
124
+
125
+ def build_hooks(collectors)
42
126
  hooks = {}
43
127
  collectors.each do |collector|
128
+ gates = collector_gates(collector)
44
129
  collector.class::NODE_CLASSES.each do |node_class|
45
- (hooks[node_class] ||= []) << collector
130
+ (hooks[node_class] ||= []) << [collector, gates]
46
131
  end
47
132
  end
48
- walk(root, hooks, in_loop_or_block: false)
49
- collectors
133
+ hooks
134
+ end
135
+
136
+ # Computes the context the children of `node` descend under from
137
+ # the node's own context. `in_loop_or_block` latches on once a
138
+ # loop / block is entered; `qualified_prefix` extends only on a
139
+ # nameable class / module (an unnamed one descends with the same
140
+ # prefix, matching IvarWrite's fall-through); `inside_def`
141
+ # latches on once a `DefNode` is entered.
142
+ def descend(node, context)
143
+ in_loop_or_block = context.in_loop_or_block ||
144
+ LOOP_OR_BLOCK_NODE_CLASSES.any? { |klass| node.is_a?(klass) }
145
+ inside_def = context.inside_def || node.is_a?(Prism::DefNode)
146
+ qualified_prefix = extend_prefix(node, context.qualified_prefix)
147
+
148
+ Context.new(
149
+ in_loop_or_block: in_loop_or_block,
150
+ qualified_prefix: qualified_prefix,
151
+ inside_def: inside_def
152
+ )
50
153
  end
51
154
 
52
155
  private
53
156
 
54
- def walk(node, hooks, in_loop_or_block:)
157
+ def collector_gates(collector)
158
+ klass = collector.class
159
+ return [] unless klass.const_defined?(:RULE_WALK_GATES)
160
+
161
+ klass::RULE_WALK_GATES.map { |gate| GATE_CONTEXT_PREDICATES.fetch(gate) }
162
+ end
163
+
164
+ def walk(node, hooks, context)
55
165
  return unless node.is_a?(Prism::Node)
56
166
 
57
- hooks[node.class]&.each { |collector| collector.visit(node) } unless in_loop_or_block
167
+ dispatch(node, hooks, context)
168
+ child_context = descend(node, context)
169
+ node.compact_child_nodes.each { |child| walk(child, hooks, child_context) }
170
+ end
171
+
172
+ def dispatch(node, hooks, context)
173
+ matched = hooks[node.class]
174
+ return if matched.nil?
175
+
176
+ matched.each do |collector, gates|
177
+ next if gates.any? { |predicate| context.public_send(predicate) }
58
178
 
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) }
179
+ collector.visit(node, context)
180
+ end
181
+ end
182
+
183
+ def extend_prefix(node, prefix)
184
+ return prefix unless CLASS_OR_MODULE_NODE_CLASSES.any? { |klass| node.is_a?(klass) }
185
+
186
+ name = qualified_name_for(node.constant_path)
187
+ name ? prefix + [name] : prefix
188
+ end
189
+
190
+ # Same shape resolution as `IvarWriteCollector#qualified_name_for`
191
+ # and `ScopeIndexer.qualified_name_for` (single-segment
192
+ # ConstantReadNode and dotted ConstantPathNode).
193
+ def qualified_name_for(constant_path_node)
194
+ case constant_path_node
195
+ when Prism::ConstantReadNode then constant_path_node.name.to_s
196
+ when Prism::ConstantPathNode then render_constant_path(constant_path_node)
197
+ end
198
+ end
199
+
200
+ def render_constant_path(node)
201
+ prefix =
202
+ case node.parent
203
+ when Prism::ConstantReadNode then "#{node.parent.name}::"
204
+ when Prism::ConstantPathNode then "#{render_constant_path(node.parent)}::"
205
+ else ""
206
+ end
207
+ "#{prefix}#{node.name}"
62
208
  end
63
209
  end
64
210
  end
@@ -56,8 +56,12 @@ module Rigor
56
56
  Result = Data.define(:clause, :body, :subject_name, :condition_source, :kind, :keyword)
57
57
 
58
58
  # ADR-53 Track B — the node classes the shared {RuleWalk}
59
- # dispatches to this collector (outside loop / block bodies).
59
+ # dispatches to this collector, and the context gate under which
60
+ # the walk suppresses it (loop / block bodies — see the envelope
61
+ # above). The legacy walk's `!in_loop_or_block` guard is now the
62
+ # walk's `:loop_or_block` gate, applied before `#visit`.
60
63
  NODE_CLASSES = [Prism::CaseNode, Prism::CaseMatchNode].freeze
64
+ RULE_WALK_GATES = [:loop_or_block].freeze
61
65
 
62
66
  def initialize(scope_index)
63
67
  @scope_index = scope_index
@@ -74,8 +78,10 @@ module Rigor
74
78
  end
75
79
 
76
80
  # {RuleWalk} entry point: the per-node logic of the legacy walk,
77
- # invoked under the same traversal contract.
78
- def visit(node)
81
+ # invoked under the same traversal contract. The `context` is
82
+ # unused — the loop / block suppression this collector relied on
83
+ # is the walk's `:loop_or_block` gate now.
84
+ def visit(node, _context = nil)
79
85
  collect_case(node)
80
86
  end
81
87