rigortype 0.3.6 → 0.3.8

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 (223) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/date.yml +6 -3
  4. data/data/builtins/ruby_core/enumerable.yml +1 -0
  5. data/data/builtins/ruby_core/exception.yml +2 -1
  6. data/data/builtins/ruby_core/file.yml +28 -14
  7. data/data/builtins/ruby_core/hash.yml +5 -2
  8. data/data/builtins/ruby_core/io.yml +33 -16
  9. data/data/builtins/ruby_core/random.yml +2 -1
  10. data/data/builtins/ruby_core/re.yml +6 -3
  11. data/data/builtins/ruby_core/struct.yml +2 -0
  12. data/data/builtins/ruby_core/time.yml +2 -1
  13. data/data/gem_overlay/activesupport/core_ext.rbs +409 -11
  14. data/docs/handbook/11-sig-gen.md +24 -14
  15. data/docs/manual/02-cli-reference.md +23 -7
  16. data/docs/manual/04-diagnostics.md +1 -1
  17. data/docs/manual/05-inspecting-types.md +20 -8
  18. data/docs/manual/07-plugins.md +4 -0
  19. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  20. data/docs/manual/plugins/rigor-activerecord.md +74 -1
  21. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  22. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  23. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  24. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  25. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  26. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  27. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  28. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  29. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  30. data/lib/rigor/analysis/check_rules.rb +383 -48
  31. data/lib/rigor/analysis/crash_signature.rb +188 -0
  32. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  33. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  34. data/lib/rigor/analysis/incremental.rb +28 -0
  35. data/lib/rigor/analysis/incremental_session.rb +63 -16
  36. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  37. data/lib/rigor/analysis/reachability/graph.rb +14 -5
  38. data/lib/rigor/analysis/reachability/scan.rb +7 -3
  39. data/lib/rigor/analysis/result.rb +32 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +41 -1
  41. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +233 -16
  42. data/lib/rigor/analysis/runner/pool_coordinator.rb +266 -47
  43. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  44. data/lib/rigor/analysis/runner/run_snapshots.rb +12 -2
  45. data/lib/rigor/analysis/runner.rb +212 -16
  46. data/lib/rigor/analysis/worker_session.rb +37 -3
  47. data/lib/rigor/bleeding_edge.rb +7 -6
  48. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  49. data/lib/rigor/builtins/imported_refinements.rb +11 -9
  50. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  51. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  52. data/lib/rigor/cache/annotation_location.rb +72 -0
  53. data/lib/rigor/cache/descriptor.rb +42 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  55. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +70 -8
  56. data/lib/rigor/cache/store.rb +17 -1
  57. data/lib/rigor/cli/coverage_command.rb +23 -18
  58. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  59. data/lib/rigor/cli/coverage_scan.rb +47 -6
  60. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  61. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  62. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  63. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  64. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  65. data/lib/rigor/cli/sig_gen_command.rb +47 -0
  66. data/lib/rigor/cli/type_of_command.rb +188 -41
  67. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  68. data/lib/rigor/cli/type_scan_command.rb +10 -1
  69. data/lib/rigor/cli.rb +1 -1
  70. data/lib/rigor/configuration.rb +7 -4
  71. data/lib/rigor/effects/plugin_facts.rb +1 -1
  72. data/lib/rigor/effects/scanner.rb +2 -3
  73. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  74. data/lib/rigor/environment/failure_slot.rb +28 -0
  75. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  76. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  77. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  78. data/lib/rigor/environment/rbs_loader.rb +743 -93
  79. data/lib/rigor/environment.rb +125 -25
  80. data/lib/rigor/inference/acceptance.rb +172 -20
  81. data/lib/rigor/inference/captured_locals.rb +62 -0
  82. data/lib/rigor/inference/content_join.rb +347 -0
  83. data/lib/rigor/inference/def_handle.rb +15 -3
  84. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  85. data/lib/rigor/inference/expression_typer.rb +1020 -141
  86. data/lib/rigor/inference/fork_map.rb +6 -1
  87. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  88. data/lib/rigor/inference/hkt_registry.rb +61 -3
  89. data/lib/rigor/inference/hkt_sugar_translator.rb +87 -0
  90. data/lib/rigor/inference/index_write_widening.rb +48 -0
  91. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  92. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  93. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  94. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  95. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  96. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  97. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  98. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  99. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  100. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  101. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  102. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  103. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  104. data/lib/rigor/inference/mutation_widening.rb +244 -174
  105. data/lib/rigor/inference/narrowing.rb +331 -27
  106. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  107. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  108. data/lib/rigor/inference/precision_scanner.rb +5 -2
  109. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  110. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  111. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  112. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  113. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  114. data/lib/rigor/inference/version_guard.rb +229 -0
  115. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  116. data/lib/rigor/plugin/base.rb +3 -2
  117. data/lib/rigor/plugin/inflector.rb +14 -5
  118. data/lib/rigor/plugin/io_boundary.rb +107 -4
  119. data/lib/rigor/plugin/loader.rb +14 -0
  120. data/lib/rigor/plugin/registry.rb +25 -2
  121. data/lib/rigor/protection/analysis_guard.rb +60 -0
  122. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  123. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  124. data/lib/rigor/protection/discovery_seed.rb +1 -1
  125. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  126. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  127. data/lib/rigor/protection/mutator.rb +121 -18
  128. data/lib/rigor/rbs_extended/envelope_scanner.rb +8 -7
  129. data/lib/rigor/rbs_extended/hkt_directives.rb +16 -1
  130. data/lib/rigor/rbs_extended/reporter.rb +93 -13
  131. data/lib/rigor/rbs_extended.rb +6 -1
  132. data/lib/rigor/reflection.rb +182 -30
  133. data/lib/rigor/scope/discovery_index.rb +39 -1
  134. data/lib/rigor/scope.rb +385 -4
  135. data/lib/rigor/sig_gen/classification.rb +12 -1
  136. data/lib/rigor/sig_gen/generator.rb +320 -85
  137. data/lib/rigor/sig_gen/renderer.rb +14 -12
  138. data/lib/rigor/sig_gen/writer.rb +3 -0
  139. data/lib/rigor/source/constant_path.rb +79 -2
  140. data/lib/rigor/triage/catalogue.rb +1 -1
  141. data/lib/rigor/type/combinator.rb +10 -0
  142. data/lib/rigor/type/maybe.rb +47 -0
  143. data/lib/rigor/type/refined.rb +1 -2
  144. data/lib/rigor/type/result.rb +53 -0
  145. data/lib/rigor/type.rb +2 -0
  146. data/lib/rigor/version.rb +1 -1
  147. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  148. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  149. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  151. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  152. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  157. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  158. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  159. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  160. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  161. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  162. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  163. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  164. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  165. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  166. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  167. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  168. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  169. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  170. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  171. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  172. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  173. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  174. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  175. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  176. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  177. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  178. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  179. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  180. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  181. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  182. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  183. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  184. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  185. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  186. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  187. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  188. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  189. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  190. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  191. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  192. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  193. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  194. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  195. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  196. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  197. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  198. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  199. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  200. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  201. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  202. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  203. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  204. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  205. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  206. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  207. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  208. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  209. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  210. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  211. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  212. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  213. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  214. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  215. data/sig/rigor/analysis/baseline.rbs +1 -1
  216. data/sig/rigor/environment.rbs +9 -0
  217. data/sig/rigor/inference.rbs +4 -1
  218. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  219. data/sig/rigor/reflection.rbs +3 -1
  220. data/sig/rigor/scope.rbs +25 -1
  221. data/sig/rigor/type.rbs +95 -1
  222. data/sig/rigor.rbs +16 -0
  223. metadata +71 -3
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../../source/constant_path"
6
+ require_relative "../../source/node_children"
7
+
8
+ module Rigor
9
+ module Analysis
10
+ module CheckRules
11
+ # Issue #644 — the shared "does this expression's constancy rest on a constant declared in ANOTHER
12
+ # file?" predicate, and the single question the truthiness rule family must ask.
13
+ #
14
+ # The cross-file value-constant table publishes `MODE = :production` project-wide so a reader can
15
+ # dispatch on it, type an argument with it, and see `MODE.upcase` fail. That is the precision the table
16
+ # exists for. What it must NOT do is let a rule *conclude* from the value: `if MODE == :production` in
17
+ # another file folds to `Constant[true]`, and reporting that as `flow.always-truthy-condition` fires on
18
+ # correct code — on the single Ruby idiom whose whole point is a project-wide constant that gets
19
+ # branched on, whose declaration the reader's author never opened. A configuration constant read in ten
20
+ # files would put a warning in all ten.
21
+ #
22
+ # This is [ADR-58](../../../../docs/adr/58-declaration-sourced-ivar-typing.md)'s discipline applied to a
23
+ # different carrier: "a consumer may only ever *withhold* a firing it would otherwise make, so the mark
24
+ # can lose precision but can never manufacture a false positive"
25
+ # (`docs/internal-spec/inference-engine.md` § "Carrier and discipline"). The carrier is deliberately NOT
26
+ # the ADR-58 `Set[[kind, name]]`: that set is flow state, keyed on a *binding* that rebinding drops, and
27
+ # it is already shared by two marks with opposite join policies (ADR-67 WD6b's `:inferred_param` unions
28
+ # where ADR-58's kinds intersect). "Which constants did the project publish, and which does this file
29
+ # declare itself" is ambient, never varies along a flow edge, and so belongs in the
30
+ # `Scope::DiscoveryIndex` — which is exactly ADR-53's membership criterion.
31
+ #
32
+ # `rooted?` is purely syntactic, in the shape of {InferredParamGuard}: it walks a predicate expression
33
+ # down to the roots its constancy could rest on and reports whether any is a published foreign
34
+ # constant. It covers
35
+ #
36
+ # - the bare predicate: `if PAGE_SIZE`, `unless AppConfig::LOG_LEVEL`
37
+ # - a comparison or any method chain on one: `MODE == :production`, `PAGE_SIZE > 100`, `VERSION >= 3`
38
+ # - a constant in ARGUMENT position: `%i[development test].include?(ENV_NAME)` — unlike
39
+ # {InferredParamGuard}, arguments are walked, because a predicate's fold rests on its arguments just
40
+ # as much as on its receiver
41
+ # - `&&` / `||` / parentheses / `rescue` composition, and `!`, which Prism spells as a `CallNode`
42
+ # - ONE interprocedural hop into a project predicate method whose body reads such a constant, whatever
43
+ # spells its owner: `unless production?` (implicit self), `self.prod?`, `AppConfig.production?` (the
44
+ # idiomatic shape — a configuration module exposing predicates), a lexically relative
45
+ # `Inner.production?`, and a call chain `KlassConfig.new.production?`. The owner comes from the
46
+ # receiver's inferred TYPE, so every spelling that names the same module lands on the same table. The
47
+ # hop is bounded (one level, a node budget) and deliberately coarse: ANY published foreign constant
48
+ # anywhere in the callee's body declines, rather than a proof that the return value derives from it.
49
+ # Over-declining withholds a warning; under-declining would emit one. ADR-58's *Non-transitivity*
50
+ # passage is the precedent for stopping at one hop rather than building a provenance channel through
51
+ # the return memo.
52
+ #
53
+ # Deliberately NOT covered, and a separate change: a value copied into a local (`m = MODE; m == :x`),
54
+ # into an ivar (`@mode = MODE` in `initialize`), or through a same-file constant alias
55
+ # (`MODE2 = AppConfig::MODE`). Each needs flow provenance — ADR-58's `r = @x` stamping is the shape —
56
+ # rather than a syntactic root walk, and approximating it here is exactly the ad-hoc widening ADR-58's
57
+ # *Non-transitivity* passage forbids. Two hops (`live? -> prod? -> MODE`) also stay uncovered.
58
+ module PublishedConstantGuard
59
+ # A defensive depth cap against a pathological chain (the walk is otherwise linear in chain length).
60
+ MAX_DEPTH = 64
61
+
62
+ # A node cap on the one-hop callee-body scan, so a predicate calling a very large method cannot make
63
+ # rule collection quadratic. Exhausting it answers false — the firing direction, and the same
64
+ # conservative default the collector's other gates take when they cannot decide.
65
+ BODY_SCAN_BUDGET = 2000
66
+
67
+ module_function
68
+
69
+ # @return [Boolean] true when the rule MUST withhold its firing.
70
+ def rooted?(node, scope, depth = 0)
71
+ return false if node.nil? || depth > MAX_DEPTH || scope.nil?
72
+
73
+ case node
74
+ when Prism::ConstantReadNode then scope.published_constant?(node.name.to_s)
75
+ when Prism::ConstantPathNode then published_path?(node, scope)
76
+ when Prism::CallNode then rooted_call?(node, scope, depth)
77
+ else rooted_through_composition?(node, scope, depth)
78
+ end
79
+ end
80
+
81
+ # `&&` / `||` / `rescue` combine two operands and a parenthesised or multi-statement body yields its
82
+ # last: the produced value is constant only if a rooted operand made it so, so declining on either
83
+ # side is the withholding direction.
84
+ def rooted_through_composition?(node, scope, depth)
85
+ case node
86
+ when Prism::AndNode, Prism::OrNode
87
+ rooted?(node.left, scope, depth + 1) || rooted?(node.right, scope, depth + 1)
88
+ when Prism::RescueModifierNode
89
+ rooted?(node.expression, scope, depth + 1) || rooted?(node.rescue_expression, scope, depth + 1)
90
+ when Prism::ParenthesesNode then rooted?(last_statement(node.body), scope, depth + 1)
91
+ when Prism::StatementsNode then rooted?(node.body.last, scope, depth + 1)
92
+ else false
93
+ end
94
+ end
95
+
96
+ # A call is rooted when its receiver is, when any argument is, or when the project method it names
97
+ # reads such a constant (the one interprocedural hop).
98
+ def rooted_call?(node, scope, depth)
99
+ return true if rooted?(node.receiver, scope, depth + 1)
100
+ return true if Array(node.arguments&.arguments).any? { |arg| rooted?(arg, scope, depth + 1) }
101
+
102
+ callee_reads_published_constant?(node, scope)
103
+ end
104
+
105
+ # The reference AS WRITTEN (`AppConfig::MODE`), so `Scope#published_constant?` can match its local
106
+ # exemption exactly rather than on the last segment. A path that renders no qualified name falls back
107
+ # to its own last segment, which is all there is to ask about.
108
+ def published_path?(node, scope)
109
+ rendered = Source::ConstantPath.qualified_name_or_nil(node) || node.name&.to_s
110
+ !rendered.nil? && scope.published_constant?(rendered)
111
+ end
112
+
113
+ # The one hop. Resolves the callee through the ordinary `Scope` accessors (so the ADR-46 recorder sees
114
+ # the read and the caller gains an edge to the callee — the conservative direction) and scans its
115
+ # body. Fails soft to false: a resolution this cannot make is not a reason to withhold.
116
+ def callee_reads_published_constant?(node, scope)
117
+ return false if scope.published_constant_names.empty?
118
+
119
+ def_node = resolve_callee(node, scope)
120
+ return false if def_node.nil?
121
+
122
+ body_reads_published_constant?(def_node.body, scope)
123
+ rescue StandardError
124
+ false
125
+ end
126
+
127
+ # `foo` / `self.foo` resolve against the enclosing self. Every other receiver resolves through its
128
+ # inferred TYPE, never through the name it was spelled with: `Inner.production?` inside `module Outer`
129
+ # is `Outer::Inner`, and keying the table on the bare `Inner` both misses that (a new warning on
130
+ # ordinary code) and, where a top-level `Inner` also exists, reads the WRONG module's methods. That is
131
+ # the defect family #635 fixed in `Narrowing`, so the engine's own resolver answers it rather than a
132
+ # walk reimplemented here. Going through the type also covers a call-chain receiver for free —
133
+ # `KlassConfig.new.production?` types `Nominal[KlassConfig]`, whose instance table is the one to read.
134
+ def resolve_callee(node, scope)
135
+ receiver = node.receiver
136
+ return resolve_self_call(node.name, scope) if receiver.nil? || receiver.is_a?(Prism::SelfNode)
137
+
138
+ resolve_typed_receiver_call(receiver, node.name, scope)
139
+ end
140
+
141
+ # A `Singleton[X]` receiver names the singleton table (`AppConfig.production?`, a module's own
142
+ # predicates); a `Nominal[X]` receiver names the instance one. Anything else — `Dynamic`, a constant
143
+ # that resolves to no project class — is not a hop this walk can make.
144
+ def resolve_typed_receiver_call(receiver, method_name, scope)
145
+ receiver_type = scope.type_of(receiver)
146
+ case receiver_type
147
+ when Type::Singleton then scope.singleton_def_for(receiver_type.class_name, method_name)
148
+ when Type::Nominal then scope.user_def_for(receiver_type.class_name, method_name)
149
+ end
150
+ end
151
+
152
+ # A `Singleton[X]` self (a `def self.…` body) names the singleton table; a `Nominal[X]` self names
153
+ # the instance one; no class at all is the top-level pseudo-class.
154
+ def resolve_self_call(method_name, scope)
155
+ self_type = scope.self_type
156
+ owner = self_type.respond_to?(:class_name) ? self_type.class_name : nil
157
+ return scope.top_level_def_for(method_name) if owner.nil?
158
+
159
+ if self_type.is_a?(Type::Singleton)
160
+ scope.singleton_def_for(owner, method_name)
161
+ else
162
+ scope.user_def_for(owner, method_name)
163
+ end
164
+ end
165
+
166
+ # An explicit worklist rather than recursion, so the node budget is one loop-carried local instead of
167
+ # a shared mutable cell (the engine value-pins a one-element Array counter and then self-flags the
168
+ # guard's own `negative?` as an always-falsey condition — this rule's own medicine).
169
+ def body_reads_published_constant?(root, scope)
170
+ stack = [root]
171
+ budget = BODY_SCAN_BUDGET
172
+ until stack.empty?
173
+ node = stack.pop
174
+ next unless node.is_a?(Prism::Node)
175
+
176
+ budget -= 1
177
+ return false if budget.negative?
178
+ return true if published_constant_node?(node, scope)
179
+
180
+ node.rigor_each_child { |child| stack << child }
181
+ end
182
+ false
183
+ end
184
+
185
+ def published_constant_node?(node, scope)
186
+ case node
187
+ when Prism::ConstantReadNode then scope.published_constant?(node.name.to_s)
188
+ when Prism::ConstantPathNode then published_path?(node, scope)
189
+ else false
190
+ end
191
+ end
192
+
193
+ def last_statement(body)
194
+ body.is_a?(Prism::StatementsNode) ? body.body.last : body
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
@@ -170,8 +170,7 @@ module Rigor
170
170
  def extend_prefix(node, prefix)
171
171
  return prefix unless CLASS_OR_MODULE_NODE_CLASSES.any? { |klass| node.is_a?(klass) }
172
172
 
173
- name = Source::ConstantPath.qualified_name(node.constant_path)
174
- name ? prefix + [name] : prefix
173
+ Source::ConstantPath.declaration_prefix(prefix, node.constant_path) || prefix
175
174
  end
176
175
  end
177
176
  end
@@ -44,12 +44,12 @@ module Rigor
44
44
  case node
45
45
  when Prism::ModuleNode
46
46
  name = Source::ConstantPath.qualified_name_or_nil(node.constant_path)
47
- child_prefix = name ? prefix + [name] : prefix
47
+ child_prefix = Source::ConstantPath.declaration_prefix(prefix, node.constant_path) || prefix
48
48
  names << child_prefix.join("::") if name
49
49
  walk(node.body, child_prefix, names) if node.body
50
50
  when Prism::ClassNode
51
51
  name = Source::ConstantPath.qualified_name_or_nil(node.constant_path)
52
- child_prefix = name ? prefix + [name] : prefix
52
+ child_prefix = Source::ConstantPath.declaration_prefix(prefix, node.constant_path) || prefix
53
53
  names << child_prefix.join("::") if name && class_surface_open?(node)
54
54
  walk(node.body, child_prefix, names) if node.body
55
55
  else
@@ -88,8 +88,7 @@ module Rigor
88
88
  def extend_prefix(node, prefix)
89
89
  return prefix unless node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
90
90
 
91
- name = Source::ConstantPath.qualified_name(node.constant_path)
92
- name ? prefix + [name] : prefix
91
+ Source::ConstantPath.declaration_prefix(prefix, node.constant_path) || prefix
93
92
  end
94
93
 
95
94
  def collect_begin(node, prefix)
@@ -160,7 +159,7 @@ module Rigor
160
159
  name = Source::ConstantPath.qualified_name_or_nil(node)
161
160
  return nil if name.nil?
162
161
 
163
- return name if absolute_path?(node)
162
+ return name if Source::ConstantPath.rooted?(node)
164
163
 
165
164
  candidates = []
166
165
  prefix.size.downto(1) { |i| candidates << (prefix.first(i) + [name]).join("::") }
@@ -168,15 +167,6 @@ module Rigor
168
167
  candidates.find { |candidate| known_name?(candidate, scope) }
169
168
  end
170
169
 
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
170
  # Presence check used to pick the lexical candidate — deliberately broader than certification, so a
181
171
  # name that resolves to a project module is FOUND here and then rejected by {#certified_class?}
182
172
  # (rather than mis-resolving outward to a same-named core class).
@@ -268,6 +258,10 @@ module Rigor
268
258
  # `Mail::Error`), longest prefix first; falls back to the as-written name (which RBS may know, e.g.
269
259
  # `StandardError`).
270
260
  def resolve_parent(child, parent, scope)
261
+ # Issue #722 residue 1 — a rooted parent is anchored at the top level; the namespace walk below
262
+ # is Ruby's lexical lookup, which `::` opts out of.
263
+ return parent.delete_prefix("::") if parent.start_with?("::")
264
+
271
265
  segments = child.to_s.split("::")[0...-1]
272
266
  until segments.empty?
273
267
  candidate = "#{segments.join('::')}::#{parent}"