rigortype 0.1.18 → 0.2.0

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 (210) 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 +32 -23
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +151 -23
  8. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  9. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +9 -3
  10. data/lib/rigor/analysis/check_rules.rb +756 -132
  11. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  12. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  13. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  14. data/lib/rigor/analysis/diagnostic.rb +8 -0
  15. data/lib/rigor/analysis/fact_store.rb +5 -4
  16. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  17. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +19 -18
  18. data/lib/rigor/analysis/runner/project_pre_passes.rb +13 -9
  19. data/lib/rigor/analysis/runner.rb +75 -27
  20. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  21. data/lib/rigor/analysis/worker_session.rb +31 -25
  22. data/lib/rigor/bleeding_edge.rb +123 -0
  23. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  24. data/lib/rigor/cache/descriptor.rb +86 -8
  25. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  26. data/lib/rigor/cache/store.rb +5 -3
  27. data/lib/rigor/cli/annotate_command.rb +122 -16
  28. data/lib/rigor/cli/baseline_command.rb +4 -3
  29. data/lib/rigor/cli/check_command.rb +118 -16
  30. data/lib/rigor/cli/coverage_command.rb +148 -16
  31. data/lib/rigor/cli/coverage_scan.rb +57 -0
  32. data/lib/rigor/cli/explain_command.rb +2 -0
  33. data/lib/rigor/cli/lsp_command.rb +3 -7
  34. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  35. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  36. data/lib/rigor/cli/options.rb +9 -0
  37. data/lib/rigor/cli/plugins_command.rb +4 -5
  38. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  39. data/lib/rigor/cli/protection_renderer.rb +63 -0
  40. data/lib/rigor/cli/protection_report.rb +68 -0
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  42. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  43. data/lib/rigor/cli/trace_command.rb +2 -1
  44. data/lib/rigor/cli/triage_command.rb +8 -4
  45. data/lib/rigor/cli/triage_renderer.rb +15 -1
  46. data/lib/rigor/cli/type_of_command.rb +1 -1
  47. data/lib/rigor/cli/type_scan_command.rb +2 -1
  48. data/lib/rigor/cli.rb +12 -3
  49. data/lib/rigor/configuration/dependencies.rb +2 -4
  50. data/lib/rigor/configuration/severity_profile.rb +13 -1
  51. data/lib/rigor/configuration.rb +100 -6
  52. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  53. data/lib/rigor/environment/class_registry.rb +4 -3
  54. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  55. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  56. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  57. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  58. data/lib/rigor/environment/rbs_loader.rb +74 -5
  59. data/lib/rigor/environment.rb +17 -7
  60. data/lib/rigor/flow_contribution/fact.rb +1 -1
  61. data/lib/rigor/flow_contribution.rb +3 -5
  62. data/lib/rigor/inference/acceptance.rb +17 -9
  63. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  64. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  65. data/lib/rigor/inference/budget_trace.rb +29 -2
  66. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  67. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  68. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  69. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  70. data/lib/rigor/inference/expression_typer.rb +1072 -71
  71. data/lib/rigor/inference/hkt_body.rb +8 -11
  72. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  73. data/lib/rigor/inference/hkt_registry.rb +10 -11
  74. data/lib/rigor/inference/macro_block_self_type.rb +2 -2
  75. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  76. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  77. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +210 -35
  78. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  79. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  80. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  81. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  82. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  83. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  84. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  85. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  86. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  87. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +237 -24
  88. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  89. data/lib/rigor/inference/method_dispatcher.rb +112 -49
  90. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  91. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  92. data/lib/rigor/inference/mutation_widening.rb +147 -11
  93. data/lib/rigor/inference/narrowing.rb +284 -53
  94. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  95. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  96. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  97. data/lib/rigor/inference/protection_scanner.rb +86 -0
  98. data/lib/rigor/inference/scope_indexer.rb +821 -76
  99. data/lib/rigor/inference/statement_evaluator.rb +1179 -102
  100. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  101. data/lib/rigor/inference/synthetic_method.rb +7 -7
  102. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  103. data/lib/rigor/language_server/completion_provider.rb +6 -12
  104. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  105. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  106. data/lib/rigor/language_server/hover_provider.rb +2 -3
  107. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  108. data/lib/rigor/language_server/server.rb +9 -17
  109. data/lib/rigor/language_server.rb +4 -5
  110. data/lib/rigor/plugin/base.rb +245 -87
  111. data/lib/rigor/plugin/macro/block_as_method.rb +25 -25
  112. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  113. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  114. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  115. data/lib/rigor/plugin/macro.rb +6 -8
  116. data/lib/rigor/plugin/manifest.rb +49 -90
  117. data/lib/rigor/plugin/node_rule_walk.rb +59 -14
  118. data/lib/rigor/plugin/registry.rb +18 -18
  119. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  120. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  121. data/lib/rigor/protection/mutator.rb +246 -0
  122. data/lib/rigor/rbs_extended.rb +24 -36
  123. data/lib/rigor/reflection.rb +4 -7
  124. data/lib/rigor/scope/discovery_index.rb +16 -2
  125. data/lib/rigor/scope.rb +185 -16
  126. data/lib/rigor/sig_gen/generator.rb +8 -0
  127. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  128. data/lib/rigor/sig_gen/writer.rb +40 -2
  129. data/lib/rigor/source/constant_path.rb +62 -0
  130. data/lib/rigor/source.rb +1 -0
  131. data/lib/rigor/triage/catalogue.rb +4 -19
  132. data/lib/rigor/triage.rb +69 -1
  133. data/lib/rigor/type/bound_method.rb +2 -11
  134. data/lib/rigor/type/combinator.rb +45 -3
  135. data/lib/rigor/type/constant.rb +2 -11
  136. data/lib/rigor/type/data_class.rb +2 -11
  137. data/lib/rigor/type/data_instance.rb +2 -11
  138. data/lib/rigor/type/hash_shape.rb +2 -11
  139. data/lib/rigor/type/integer_range.rb +2 -11
  140. data/lib/rigor/type/intersection.rb +2 -11
  141. data/lib/rigor/type/nominal.rb +2 -11
  142. data/lib/rigor/type/plain_lattice.rb +37 -0
  143. data/lib/rigor/type/refined.rb +72 -13
  144. data/lib/rigor/type/singleton.rb +2 -11
  145. data/lib/rigor/type/struct_class.rb +75 -0
  146. data/lib/rigor/type/struct_instance.rb +93 -0
  147. data/lib/rigor/type/tuple.rb +5 -15
  148. data/lib/rigor/type.rb +2 -0
  149. data/lib/rigor/version.rb +1 -1
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  151. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +16 -32
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +34 -100
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  158. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +26 -27
  161. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  162. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +9 -8
  163. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  164. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  165. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  166. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  167. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  169. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  170. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +18 -49
  171. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  172. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  173. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +4 -4
  174. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  175. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  176. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  177. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  178. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +22 -35
  179. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  180. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  181. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +16 -23
  182. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  184. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  185. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  186. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +21 -27
  187. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  188. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  189. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  190. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  191. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  192. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  193. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  194. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  195. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  196. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  197. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +52 -40
  198. data/sig/rigor/analysis/fact_store.rbs +3 -0
  199. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  200. data/sig/rigor/plugin/base.rbs +5 -2
  201. data/sig/rigor/plugin/manifest.rbs +1 -2
  202. data/sig/rigor/scope.rbs +18 -1
  203. data/sig/rigor/type.rbs +37 -1
  204. data/sig/rigor.rbs +1 -1
  205. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  206. data/skills/rigor-plugin-author/SKILL.md +6 -4
  207. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  208. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  209. metadata +25 -2
  210. data/lib/rigor/plugin/macro/external_file.rb +0 -143
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Analysis
7
+ module CheckRules
8
+ # ADR-53 Track B (slice B3c) — hosts the main per-node check-rule
9
+ # pass on the shared {RuleWalk} instead of its own
10
+ # `Source::NodeWalker.each` traversal.
11
+ #
12
+ # The main pass is the stateless half of the catalogue: each rule
13
+ # reads only `scope_index[node]` and decides, with NO loop / block
14
+ # suppression and NO threaded context — so the collector declares no
15
+ # `RULE_WALK_GATES` and ignores the walk's `context`.
16
+ #
17
+ # The per-node dispatch itself stays in `CheckRules` (the verbatim
18
+ # `case` from `diagnose`'s former inline walk — only the traversal
19
+ # moved, ADR-53 WD4) and is handed in as a callable built in the
20
+ # `CheckRules` module context, so its calls to the private
21
+ # diagnostic builders remain implicit-self. The collector only owns
22
+ # the traversal hook and accumulation. Unlike the fact collectors
23
+ # its `#results` are the accumulated {Diagnostic}s, in the same
24
+ # emission order the inline `NodeWalker.each` produced them (the
25
+ # shared walk is the same visit-before-descend DFS over
26
+ # `compact_child_nodes`).
27
+ class MainPassCollector
28
+ # The node classes the former inline pass branched on. A plain
29
+ # `Prism::IfNode` covers ternaries and postfix `if` too (the
30
+ # legacy `when Prism::IfNode, Prism::UnlessNode` arm did the same).
31
+ NODE_CLASSES = [
32
+ Prism::CallNode, Prism::DefNode, Prism::IfNode, Prism::UnlessNode
33
+ ].freeze
34
+
35
+ # @param node_diagnostics [#call] maps a `Prism::Node` to the
36
+ # array of diagnostics the main pass emits for it.
37
+ def initialize(node_diagnostics)
38
+ @node_diagnostics = node_diagnostics
39
+ @diagnostics = []
40
+ end
41
+
42
+ # {RuleWalk} entry point: the per-node logic of the former inline
43
+ # `NodeWalker.each` `case`, invoked under the shared traversal.
44
+ def visit(node, _context = nil)
45
+ @diagnostics.concat(@node_diagnostics.call(node))
46
+ end
47
+
48
+ def results
49
+ @diagnostics
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "prism"
4
4
 
5
+ require_relative "../../source/constant_path"
6
+
5
7
  module Rigor
6
8
  module Analysis
7
9
  module CheckRules
@@ -11,54 +13,180 @@ module Rigor
11
13
  # the built-in rules; this walk and {Plugin::NodeRuleWalk} converge
12
14
  # in slice B4).
13
15
  #
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.
16
+ # The walk is a single visit-before-descend DFS over
17
+ # `compact_child_nodes`. It threads the union of the context the
18
+ # hosted collectors need — the per-collector hand-rolled walks each
19
+ # tracked some subset of it — in one immutable {Context} object,
20
+ # recomputed once per node during the single descent:
21
+ #
22
+ # - `in_loop_or_block` whether the node is inside a loop / block
23
+ # body (the two flow collectors suppress collection there because
24
+ # mutation tracking through those is incomplete).
25
+ # - `qualified_prefix` the enclosing class / module name stack
26
+ # ({IvarWriteCollector} builds its `class_name` from it).
27
+ # - `inside_def` — whether the node is lexically inside a `DefNode`
28
+ # ({IvarWriteCollector} collects only at a `def` that sits
29
+ # directly in a class / module body, never one nested in another
30
+ # `def`).
31
+ #
32
+ # A collector joins the walk by declaring `NODE_CLASSES` (the node
33
+ # classes it cares about — Prism node classes are leaves, so a
34
+ # class-equality dispatch matches the collectors' `is_a?` gates),
35
+ # optionally `RULE_WALK_GATES` (the {Context}-derived suppressions
36
+ # the walk applies before calling it), and `#visit(node, context)`
37
+ # with its per-node logic transplanted verbatim. Only the
38
+ # *traversal* merges here; each collector's gather / filter logic is
39
+ # unchanged from its legacy `#collect` walk (ADR-53 WD4).
25
40
  #
26
41
  # Equivalence is load-bearing: every hosted collector keeps its
27
42
  # legacy single-collector `#collect` walk as the oracle, compared
28
43
  # against this walk by the permanent `rule_walk_equivalence_spec`
29
44
  # and, over whole corpora, by `RIGOR_SHADOW_RULE_WALK=1`
30
- # (see `CheckRules.flow_collector_results`).
45
+ # (see `CheckRules.run_node_collectors`).
31
46
  module RuleWalk
32
47
  LOOP_OR_BLOCK_NODE_CLASSES = [
33
48
  Prism::WhileNode, Prism::UntilNode, Prism::ForNode, Prism::BlockNode
34
49
  ].freeze
35
50
 
51
+ CLASS_OR_MODULE_NODE_CLASSES = [Prism::ClassNode, Prism::ModuleNode].freeze
52
+
53
+ # The immutable per-node descent context. Recomputed for each
54
+ # child from its parent's context as the walk descends; collectors
55
+ # read only the fields they declared a need for.
56
+ Context = Data.define(:in_loop_or_block, :qualified_prefix, :inside_def) do
57
+ def self.root
58
+ new(in_loop_or_block: false, qualified_prefix: [], inside_def: false)
59
+ end
60
+ end
61
+
62
+ # Suppression gates a collector may declare via `RULE_WALK_GATES`.
63
+ # The walk skips a collector's `#visit` for a node when any of the
64
+ # collector's declared gates holds in that node's context — exactly
65
+ # reproducing the per-collector legacy walk's traversal suppression.
66
+ # Each gate names the {Context} predicate method that, when true,
67
+ # suppresses the visit:
68
+ #
69
+ # - `:loop_or_block` (`in_loop_or_block`) — inside a loop / block
70
+ # body, the two flow collectors' envelope.
71
+ # - `:inside_def` (`inside_def`) — lexically inside a `DefNode`;
72
+ # IvarWrite collects only at a `def` that sits directly in a
73
+ # class / module body (its legacy walk `return`s at the first
74
+ # `def` it meets, so a nested def is never reached).
75
+ GATE_CONTEXT_PREDICATES = {
76
+ loop_or_block: :in_loop_or_block,
77
+ inside_def: :inside_def
78
+ }.freeze
79
+
80
+ # A per-node driver over a fixed collector set: holds the compiled
81
+ # `node_class => [[collector, gates], …]` hook table and applies
82
+ # the dispatch + context-descent that {RuleWalk.run} performs, but
83
+ # one node at a time. This lets a foreign traversal (the converged
84
+ # {Plugin::NodeRuleWalk}, ADR-53 B4) drive the built-in collectors
85
+ # from inside its own single walk: it calls {#visit} on each node
86
+ # with that node's {Context}, and derives a child's context with
87
+ # {#descend}. The dispatch / gate / descend logic is identical to
88
+ # the standalone {RuleWalk.run} walk — only the traversal driving
89
+ # it differs, keeping diagnostics byte-identical (ADR-53 WD4).
90
+ class CollectorDriver
91
+ def initialize(collectors)
92
+ @hooks = RuleWalk.build_hooks(collectors)
93
+ freeze
94
+ end
95
+
96
+ # Dispatch `node` (under its own `context`) to every matching,
97
+ # un-gated collector. Mirrors {RuleWalk.dispatch}.
98
+ def visit(node, context)
99
+ matched = @hooks[node.class]
100
+ return if matched.nil?
101
+
102
+ matched.each do |collector, gates|
103
+ next if gates.any? { |predicate| context.public_send(predicate) }
104
+
105
+ collector.visit(node, context)
106
+ end
107
+ end
108
+
109
+ # The context the children of `node` descend under. Mirrors
110
+ # {RuleWalk.descend}.
111
+ def descend(node, context)
112
+ RuleWalk.descend(node, context)
113
+ end
114
+ end
115
+
36
116
  class << self
37
117
  # 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.
118
+ # collector whose `NODE_CLASSES` covers the node's class and
119
+ # whose declared `RULE_WALK_GATES` do not suppress it in that
120
+ # node's context.
41
121
  def run(root, collectors)
122
+ hooks = build_hooks(collectors)
123
+ walk(root, hooks, Context.root)
124
+ collectors
125
+ end
126
+
127
+ def build_hooks(collectors)
42
128
  hooks = {}
43
129
  collectors.each do |collector|
130
+ gates = collector_gates(collector)
44
131
  collector.class::NODE_CLASSES.each do |node_class|
45
- (hooks[node_class] ||= []) << collector
132
+ (hooks[node_class] ||= []) << [collector, gates]
46
133
  end
47
134
  end
48
- walk(root, hooks, in_loop_or_block: false)
49
- collectors
135
+ hooks
136
+ end
137
+
138
+ # Computes the context the children of `node` descend under from
139
+ # the node's own context. `in_loop_or_block` latches on once a
140
+ # loop / block is entered; `qualified_prefix` extends only on a
141
+ # nameable class / module (an unnamed one descends with the same
142
+ # prefix, matching IvarWrite's fall-through); `inside_def`
143
+ # latches on once a `DefNode` is entered.
144
+ def descend(node, context)
145
+ in_loop_or_block = context.in_loop_or_block ||
146
+ LOOP_OR_BLOCK_NODE_CLASSES.any? { |klass| node.is_a?(klass) }
147
+ inside_def = context.inside_def || node.is_a?(Prism::DefNode)
148
+ qualified_prefix = extend_prefix(node, context.qualified_prefix)
149
+
150
+ Context.new(
151
+ in_loop_or_block: in_loop_or_block,
152
+ qualified_prefix: qualified_prefix,
153
+ inside_def: inside_def
154
+ )
50
155
  end
51
156
 
52
157
  private
53
158
 
54
- def walk(node, hooks, in_loop_or_block:)
159
+ def collector_gates(collector)
160
+ klass = collector.class
161
+ return [] unless klass.const_defined?(:RULE_WALK_GATES)
162
+
163
+ klass::RULE_WALK_GATES.map { |gate| GATE_CONTEXT_PREDICATES.fetch(gate) }
164
+ end
165
+
166
+ def walk(node, hooks, context)
55
167
  return unless node.is_a?(Prism::Node)
56
168
 
57
- hooks[node.class]&.each { |collector| collector.visit(node) } unless in_loop_or_block
169
+ dispatch(node, hooks, context)
170
+ child_context = descend(node, context)
171
+ node.compact_child_nodes.each { |child| walk(child, hooks, child_context) }
172
+ end
173
+
174
+ def dispatch(node, hooks, context)
175
+ matched = hooks[node.class]
176
+ return if matched.nil?
177
+
178
+ matched.each do |collector, gates|
179
+ next if gates.any? { |predicate| context.public_send(predicate) }
180
+
181
+ collector.visit(node, context)
182
+ end
183
+ end
184
+
185
+ def extend_prefix(node, prefix)
186
+ return prefix unless CLASS_OR_MODULE_NODE_CLASSES.any? { |klass| node.is_a?(klass) }
58
187
 
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) }
188
+ name = Source::ConstantPath.qualified_name(node.constant_path)
189
+ name ? prefix + [name] : prefix
62
190
  end
63
191
  end
64
192
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "prism"
4
4
 
5
+ require_relative "../../source/constant_path"
6
+
5
7
  module Rigor
6
8
  module Analysis
7
9
  module CheckRules
@@ -45,20 +47,27 @@ module Rigor
45
47
 
46
48
  case node
47
49
  when Prism::ModuleNode
48
- name = constant_path_name(node.constant_path)
50
+ name = Source::ConstantPath.qualified_name_or_nil(node.constant_path)
49
51
  child_prefix = name ? prefix + [name] : prefix
50
52
  names << child_prefix.join("::") if name
51
53
  walk(node.body, child_prefix, names) if node.body
52
54
  when Prism::ClassNode
53
- name = constant_path_name(node.constant_path)
55
+ name = Source::ConstantPath.qualified_name_or_nil(node.constant_path)
54
56
  child_prefix = name ? prefix + [name] : prefix
55
- names << child_prefix.join("::") if name && dynamic_attr_class?(node)
57
+ names << child_prefix.join("::") if name && class_surface_open?(node)
56
58
  walk(node.body, child_prefix, names) if node.body
57
59
  else
58
60
  node.compact_child_nodes.each { |child| walk(child, prefix, names) }
59
61
  end
60
62
  end
61
63
 
64
+ # A class whose source-declared surface cannot be fully enumerated:
65
+ # a dynamic `attr_*` accessor, or a non-constant (dynamically produced)
66
+ # superclass.
67
+ def class_surface_open?(class_node)
68
+ dynamic_attr_class?(class_node) || dynamic_superclass?(class_node)
69
+ end
70
+
62
71
  # True when the class body directly invokes an `attr_*` macro with a
63
72
  # non-literal argument (a splat, a constant, a method call) — the
64
73
  # synthesized accessor names are then not statically knowable.
@@ -81,18 +90,18 @@ module Rigor
81
90
  args.any? { |arg| !arg.is_a?(Prism::SymbolNode) && !arg.is_a?(Prism::StringNode) }
82
91
  end
83
92
 
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
93
+ # True when the class declares a superclass that is not a static
94
+ # constant `class X < DelegateClass(Array)` / `< Struct.new(...)` /
95
+ # `< Data.define(...)`. The inherited surface is then a dynamically
96
+ # produced class the engine cannot enumerate from a constant name (the
97
+ # superclass is a method call, so `discovered_superclasses` never
98
+ # records it and the closed-class gate would wrongly treat the class as
99
+ # standalone), so a missed self-call is not provably a typo.
100
+ def dynamic_superclass?(class_node)
101
+ superclass = class_node.superclass
102
+ return false if superclass.nil?
103
+
104
+ !superclass.is_a?(Prism::ConstantReadNode) && !superclass.is_a?(Prism::ConstantPathNode)
96
105
  end
97
106
  end
98
107
  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