rigortype 0.2.8 → 0.3.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 (207) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/core_overlay/csv.rbs +28 -0
  4. data/data/core_overlay/psych.rbs +22 -0
  5. data/docs/handbook/01-getting-started.md +9 -1
  6. data/docs/handbook/02-everyday-types.md +4 -1
  7. data/docs/handbook/03-narrowing.md +2 -1
  8. data/docs/handbook/04-tuples-and-shapes.md +7 -3
  9. data/docs/handbook/06-classes.md +18 -9
  10. data/docs/handbook/08-understanding-errors.md +4 -3
  11. data/docs/handbook/11-sig-gen.md +11 -1
  12. data/docs/handbook/appendix-mypy.md +4 -3
  13. data/docs/install.md +14 -6
  14. data/docs/manual/01-installation.md +99 -4
  15. data/docs/manual/02-cli-reference.md +65 -19
  16. data/docs/manual/03-configuration.md +34 -2
  17. data/docs/manual/04-diagnostics.md +23 -0
  18. data/docs/manual/07-plugins.md +5 -5
  19. data/docs/manual/09-editor-integration.md +4 -4
  20. data/docs/manual/11-ci.md +51 -0
  21. data/docs/manual/12-caching.md +65 -0
  22. data/docs/manual/14-rails-quickstart.md +4 -2
  23. data/docs/manual/15-type-protection-coverage.md +21 -0
  24. data/docs/manual/plugins/rigor-actionpack.md +1 -1
  25. data/docs/manual/plugins/rigor-activerecord.md +12 -5
  26. data/docs/manual/plugins/rigor-rails-routes.md +11 -0
  27. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
  28. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
  29. data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
  30. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
  31. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
  32. data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
  33. data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
  34. data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
  35. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
  36. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
  37. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
  38. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +93 -0
  39. data/lib/rigor/analysis/check_rules.rb +557 -95
  40. data/lib/rigor/analysis/dependency_recorder.rb +93 -9
  41. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  42. data/lib/rigor/analysis/incremental_session.rb +456 -51
  43. data/lib/rigor/analysis/path_expansion.rb +42 -0
  44. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
  45. data/lib/rigor/analysis/rule_catalog.rb +226 -1
  46. data/lib/rigor/analysis/run_cache_key.rb +68 -0
  47. data/lib/rigor/analysis/run_cache_probe.rb +72 -0
  48. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
  49. data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
  50. data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
  51. data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
  52. data/lib/rigor/analysis/runner.rb +343 -68
  53. data/lib/rigor/analysis/severity_stamp.rb +43 -0
  54. data/lib/rigor/analysis/worker_session.rb +30 -2
  55. data/lib/rigor/bleeding_edge.rb +37 -8
  56. data/lib/rigor/builtins/regex_refinement.rb +36 -1
  57. data/lib/rigor/cache/descriptor.rb +88 -28
  58. data/lib/rigor/cache/file_digest.rb +158 -0
  59. data/lib/rigor/cache/incremental_snapshot.rb +41 -5
  60. data/lib/rigor/cache/rbs_descriptor.rb +56 -10
  61. data/lib/rigor/cache/store.rb +207 -60
  62. data/lib/rigor/ci_detector.rb +90 -0
  63. data/lib/rigor/cli/annotate_command.rb +15 -9
  64. data/lib/rigor/cli/baseline_command.rb +4 -1
  65. data/lib/rigor/cli/check_command.rb +212 -27
  66. data/lib/rigor/cli/coverage_command.rb +56 -30
  67. data/lib/rigor/cli/diagnostic_formats.rb +4 -1
  68. data/lib/rigor/cli/docs_command.rb +0 -30
  69. data/lib/rigor/cli/doctor_command.rb +163 -23
  70. data/lib/rigor/cli/lsp_command.rb +5 -0
  71. data/lib/rigor/cli/mcp_command.rb +5 -0
  72. data/lib/rigor/cli/plugins_command.rb +36 -6
  73. data/lib/rigor/cli/plugins_renderer.rb +44 -12
  74. data/lib/rigor/cli/probe_environment.rb +85 -0
  75. data/lib/rigor/cli/protection_fork_scan.rb +55 -0
  76. data/lib/rigor/cli/protection_report.rb +7 -1
  77. data/lib/rigor/cli/sig_gen_command.rb +36 -9
  78. data/lib/rigor/cli/skill_command.rb +1 -32
  79. data/lib/rigor/cli/trace_command.rb +5 -6
  80. data/lib/rigor/cli/type_of_command.rb +9 -8
  81. data/lib/rigor/cli/type_scan_command.rb +8 -8
  82. data/lib/rigor/cli.rb +15 -4
  83. data/lib/rigor/config_audit.rb +48 -2
  84. data/lib/rigor/configuration/severity_profile.rb +31 -0
  85. data/lib/rigor/configuration.rb +156 -8
  86. data/lib/rigor/environment/default_libraries.rb +35 -0
  87. data/lib/rigor/environment/missing_gem_constant_index.rb +128 -0
  88. data/lib/rigor/environment/rbs_loader.rb +164 -5
  89. data/lib/rigor/environment.rb +71 -43
  90. data/lib/rigor/inference/budget_trace.rb +77 -2
  91. data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
  92. data/lib/rigor/inference/def_handle.rb +23 -0
  93. data/lib/rigor/inference/def_node_resolver.rb +90 -0
  94. data/lib/rigor/inference/def_return_typer.rb +2 -1
  95. data/lib/rigor/inference/expression_typer.rb +379 -113
  96. data/lib/rigor/inference/fork_map.rb +87 -0
  97. data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
  98. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
  99. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
  100. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
  101. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +11 -0
  102. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
  103. data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
  104. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
  105. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
  106. data/lib/rigor/inference/method_dispatcher.rb +57 -1
  107. data/lib/rigor/inference/mutation_widening.rb +42 -8
  108. data/lib/rigor/inference/narrowing.rb +245 -17
  109. data/lib/rigor/inference/parameter_inference_collector.rb +70 -10
  110. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  111. data/lib/rigor/inference/rbs_type_translator.rb +8 -1
  112. data/lib/rigor/inference/scope_indexer.rb +460 -49
  113. data/lib/rigor/inference/statement_evaluator.rb +111 -17
  114. data/lib/rigor/inference/struct_fold_safety.rb +48 -11
  115. data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
  116. data/lib/rigor/inference/void_origin.rb +25 -0
  117. data/lib/rigor/inference/void_tail_summary.rb +220 -0
  118. data/lib/rigor/language_server/completion_provider.rb +2 -1
  119. data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
  120. data/lib/rigor/language_server/folding_range_provider.rb +2 -1
  121. data/lib/rigor/language_server/selection_range_provider.rb +2 -1
  122. data/lib/rigor/language_server/signature_help_provider.rb +2 -1
  123. data/lib/rigor/plugin/base.rb +9 -22
  124. data/lib/rigor/plugin/inflector.rb +12 -3
  125. data/lib/rigor/plugin/io_boundary.rb +5 -1
  126. data/lib/rigor/plugin/isolation.rb +81 -11
  127. data/lib/rigor/plugin/load_error.rb +10 -1
  128. data/lib/rigor/plugin/loader.rb +96 -14
  129. data/lib/rigor/plugin/node_rule_walk.rb +5 -3
  130. data/lib/rigor/plugin/registry.rb +32 -23
  131. data/lib/rigor/plugin.rb +26 -0
  132. data/lib/rigor/protection/mutator.rb +3 -2
  133. data/lib/rigor/reflection.rb +64 -0
  134. data/lib/rigor/runtime/jit.rb +128 -0
  135. data/lib/rigor/scope/discovery_index.rb +12 -2
  136. data/lib/rigor/scope.rb +122 -28
  137. data/lib/rigor/sig_gen/classification.rb +5 -1
  138. data/lib/rigor/sig_gen/generator.rb +38 -6
  139. data/lib/rigor/sig_gen/observation_collector.rb +4 -3
  140. data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
  141. data/lib/rigor/sig_gen/renderer.rb +10 -0
  142. data/lib/rigor/sig_gen/write_result.rb +9 -4
  143. data/lib/rigor/sig_gen/writer.rb +24 -2
  144. data/lib/rigor/source/node_children.rb +116 -0
  145. data/lib/rigor/source/node_locator.rb +3 -1
  146. data/lib/rigor/source/node_walker.rb +4 -2
  147. data/lib/rigor/source.rb +1 -0
  148. data/lib/rigor/type/difference.rb +28 -24
  149. data/lib/rigor/type/hash_shape.rb +34 -10
  150. data/lib/rigor/version.rb +1 -1
  151. data/lib/rigortype.rb +24 -0
  152. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
  154. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +23 -8
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +21 -0
  157. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
  158. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +13 -2
  159. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +65 -2
  160. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +20 -2
  161. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
  162. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +172 -0
  163. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +22 -8
  164. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
  165. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
  166. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +32 -0
  167. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
  168. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
  169. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
  170. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
  171. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
  172. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
  173. data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
  174. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +191 -0
  175. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
  176. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +19 -1
  177. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +48 -15
  178. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +42 -12
  179. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
  180. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
  181. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
  182. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
  183. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
  184. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
  185. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
  186. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
  187. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
  188. data/sig/prism_node_children.rbs +9 -0
  189. data/sig/rigor/environment.rbs +1 -0
  190. data/sig/rigor/inference.rbs +7 -0
  191. data/sig/rigor/plugin/base.rbs +2 -3
  192. data/sig/rigor/reflection.rbs +1 -0
  193. data/sig/rigor/scope.rbs +12 -1
  194. data/skills/rigor-ci-setup/SKILL.md +10 -0
  195. data/skills/rigor-editor-setup/SKILL.md +11 -0
  196. data/skills/rigor-next-steps/SKILL.md +23 -2
  197. data/skills/rigor-plugin-author/SKILL.md +3 -3
  198. data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
  199. data/skills/rigor-plugin-review/SKILL.md +2 -2
  200. data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
  201. data/skills/rigor-project-init/SKILL.md +29 -3
  202. data/skills/rigor-project-init/references/01-detect.md +1 -1
  203. data/skills/rigor-project-init/references/02-configure.md +39 -5
  204. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
  205. data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
  206. metadata +49 -24
  207. data/lib/rigor/cli/ci_detector.rb +0 -89
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "void_origin"
6
+ require_relative "../reflection"
7
+ require_relative "../source/node_children"
8
+
9
+ module Rigor
10
+ module Inference
11
+ # ADR-100 WD4 — the lazy per-`def` "void tail" summary that carries an author-declared `-> void`
12
+ # provenance ACROSS an intermediate method whose own signature declares nothing. The direct case
13
+ # (`x = logger.log(...)`, an RBS `-> void` resolved on the receiver's own class) is recorded at the
14
+ # call site by {MethodDispatcher::RbsDispatch}; this summary answers the transitive case
15
+ # (`def bar; foo; end; b = bar`) where the `void` reaches the use through `bar`'s body, so no rule
16
+ # reading `bar`'s signature can see it.
17
+ #
18
+ # `VoidTail(def_node) -> VoidOrigin | nil`: computed on demand at the first consult, memoised per
19
+ # `def_node` (identity-keyed), cycle-guarded (a def already on the current chain rejects), and
20
+ # **pure** — AST shape + RBS reflection ({Reflection.instance_method_definition} /
21
+ # {Reflection.singleton_method_definition}) + discovery-index lookups ({Scope#user_def_for} /
22
+ # {Scope#singleton_def_for}) only, never expression evaluation. Because it never evaluates, it can
23
+ # neither re-enter the dispatcher nor depend on evaluation order or the fork-pool's file
24
+ # partitioning — the eager record-at-body-evaluation table WD4 rejected failed exactly there.
25
+ #
26
+ # A `def` (resolved on `owner` / `kind`) is admitted iff all four hold:
27
+ #
28
+ # 1. its OWN resolved RBS signature for `(owner, name, kind)` is absent, or every overload
29
+ # returns `untyped` (`RBS::Types::Bases::Any`). An author-declared concrete return — including
30
+ # `-> void` itself, which the direct rule already serves at the call site — keeps it out, so
31
+ # the direct rule and the summary are disjoint by construction (no double record).
32
+ # 2. its body is a plain statements body (no `rescue` / `else` / `ensure` on the `def` — those
33
+ # shapes make `def_node.body` a `Prism::BeginNode`) containing NO `Prism::ReturnNode` anywhere:
34
+ # a bare `return` yields `nil`, a non-void path, so the void tail must be the SOLE return path.
35
+ # The walk is deliberately over-conservative — a `return` inside a nested block also
36
+ # disqualifies.
37
+ # 3. its tail (last) expression is a `Prism::CallNode` with an implicit-`self` receiver (`nil`
38
+ # receiver or a literal `self`).
39
+ # 4. that tail resolves on the SAME owner (exact class, no ancestor walk on the discovery side) to
40
+ # either (a) an RBS definition whose EVERY overload returns `void` — the leaf, whose origin is
41
+ # `VoidOrigin(owner, tail_name, kind)` — or (b) another discovered `def` of the same kind, which
42
+ # recurses. Composition (`def baz; bar; end`) is the recursion, and the origin stays the leaf,
43
+ # so the message still names the author's `-> void` method. The RBS-void-leaf branch is checked
44
+ # FIRST because under ADR-93 auto-wire the leaf `def` is BOTH RBS-`void` and a discovered `def`;
45
+ # recursing into its body would miss the leaf.
46
+ #
47
+ # Everything else — explicit-receiver tails, conditional / boolean / `begin` tails, unresolvable
48
+ # names, non-void concrete RBS — rejects.
49
+ #
50
+ # Each tail chain is a functional graph (every `def` has exactly one tail, hence at most one
51
+ # successor), so a `def`'s result is fully determined by its own forward chain and is independent of
52
+ # the entry point: a chain either reaches a void leaf (`VoidOrigin`), a non-admitted node (nil), or a
53
+ # cycle (nil). Memoising is therefore always sound, and the cycle guard only ever rejects a node that
54
+ # is still mid-computation on the current chain (never yet memoised).
55
+ class VoidTailSummary
56
+ # Run-scoped memo, the ADR-84 `return_memo_bucket` house pattern: a single retained slot on
57
+ # `Thread.current` keyed by the run-generation token's identity, so a re-run in one process (LSP,
58
+ # ADR-62 warm loop) lands in a fresh bucket and can neither serve stale entries nor accumulate
59
+ # them. Thread-local ⇒ each fork-pool worker (a separate process) and each Ractor-pool worker (its
60
+ # own Ractor-local `Thread.current`) builds its own bucket; the bucket hash is created and mutated
61
+ # inside the worker and never shared, so no frozen / shared object is mutated and no
62
+ # graceful-degradation branch is needed. `def_node` identity is stable per run (the ADR-85
63
+ # {DefNodeResolver} yields one node object per `(path, node_id)`), the same identity the ADR-84
64
+ # return memo relies on.
65
+ MEMO_KEY = :__rigor_void_tail_summary_memo__
66
+ private_constant :MEMO_KEY
67
+
68
+ def initialize(scope)
69
+ @scope = scope
70
+ @environment = scope.environment
71
+ end
72
+
73
+ # The recovered `-> void` origin for `def_node` (whose owner / kind the caller resolved through
74
+ # the discovery index), or nil when the `def` is not admitted. A fresh visited set per top-level
75
+ # consult; the memo persists across consults for the run. The memo is probed before the visited
76
+ # set is allocated so the common post-warmup hit path allocates nothing.
77
+ #
78
+ # @param def_node [Prism::DefNode]
79
+ # @param owner [String] the qualified receiver class the `def` belongs to.
80
+ # @param kind [Symbol] `:instance` or `:singleton`.
81
+ # @return [Inference::VoidOrigin, nil]
82
+ def origin_for(def_node, owner, kind)
83
+ return nil if def_node.nil?
84
+
85
+ memo = memo_bucket
86
+ return memo[def_node] if memo.key?(def_node)
87
+
88
+ walk(def_node, owner, kind, {}.compare_by_identity)
89
+ end
90
+
91
+ private
92
+
93
+ attr_reader :scope, :environment
94
+
95
+ # Memo + cycle-guarded computation. A def already on the current chain rejects WITHOUT memoising
96
+ # (it is mid-computation; its true result is memoised when its own frame completes).
97
+ def walk(def_node, owner, kind, visited)
98
+ return nil if def_node.nil?
99
+ return nil if visited.key?(def_node)
100
+
101
+ memo = memo_bucket
102
+ return memo[def_node] if memo.key?(def_node)
103
+
104
+ visited[def_node] = true
105
+ memo[def_node] = compute(def_node, owner, kind, visited)
106
+ end
107
+
108
+ def compute(def_node, owner, kind, visited)
109
+ return nil unless own_signature_admits?(owner, def_node.name, kind) # admission 1
110
+
111
+ tail = admissible_tail(def_node) # admissions 2 + 3
112
+ return nil if tail.nil?
113
+
114
+ resolve_tail(tail.name, owner, kind, visited) # admission 4
115
+ end
116
+
117
+ # Admission 1 — absent own signature, or every overload `untyped`. `untyped` is read as "no claim"
118
+ # per RBS's own semantics, so a hand-written `-> untyped` intermediate still admits: the provenance
119
+ # fact (this value was produced by an author-declared `-> void` return) remains true of it.
120
+ def own_signature_admits?(owner, method_name, kind)
121
+ definition = method_definition(owner, method_name, kind)
122
+ return true if definition.nil?
123
+
124
+ every_overload_return?(definition, RBS::Types::Bases::Any)
125
+ end
126
+
127
+ # Admissions 2 + 3 — the tail expression when the body is a plain statements body with no
128
+ # `ReturnNode` anywhere and the tail is an implicit-self call; nil (reject) otherwise.
129
+ def admissible_tail(def_node)
130
+ body = def_node.body
131
+ return nil if body.nil?
132
+ return nil if body.is_a?(Prism::BeginNode) # def-level rescue / else / ensure
133
+ return nil if contains_return?(body)
134
+
135
+ tail = tail_expression(body)
136
+ return nil unless tail.is_a?(Prism::CallNode)
137
+ return nil unless implicit_self_receiver?(tail.receiver)
138
+
139
+ tail
140
+ end
141
+
142
+ # A `Prism::StatementsNode` (the plain and endless-def shapes both parse to one) tails on its last
143
+ # statement; any other surviving shape is treated as the tail expression itself.
144
+ def tail_expression(body)
145
+ case body
146
+ when Prism::StatementsNode then body.body.last
147
+ else body
148
+ end
149
+ end
150
+
151
+ def implicit_self_receiver?(receiver)
152
+ receiver.nil? || receiver.is_a?(Prism::SelfNode)
153
+ end
154
+
155
+ # True when a `Prism::ReturnNode` appears anywhere in the subtree — nested blocks and defs
156
+ # included (over-conservative is correct: a method that can return anything other than the void
157
+ # tail must never enter the table).
158
+ def contains_return?(node)
159
+ return false unless node.is_a?(Prism::Node)
160
+ return true if node.is_a?(Prism::ReturnNode)
161
+
162
+ node.rigor_each_child { |child| return true if contains_return?(child) }
163
+ false
164
+ end
165
+
166
+ # Admission 4 — the void leaf (RBS every-overload `void`) wins over the discovered-def recursion,
167
+ # because under ADR-93 auto-wire the annotated leaf is both.
168
+ def resolve_tail(tail_name, owner, kind, visited)
169
+ definition = method_definition(owner, tail_name, kind)
170
+ if definition && every_overload_return?(definition, RBS::Types::Bases::Void)
171
+ return VoidOrigin.new(class_name: owner, method_name: tail_name, kind: kind)
172
+ end
173
+
174
+ next_def = discovered_def(owner, tail_name, kind)
175
+ return nil if next_def.nil?
176
+
177
+ walk(next_def, owner, kind, visited)
178
+ end
179
+
180
+ def method_definition(owner, method_name, kind)
181
+ if kind == :singleton
182
+ Reflection.singleton_method_definition(owner, method_name, environment: environment)
183
+ else
184
+ Reflection.instance_method_definition(owner, method_name, environment: environment)
185
+ end
186
+ end
187
+
188
+ def discovered_def(owner, method_name, kind)
189
+ if kind == :singleton
190
+ scope.singleton_def_for(owner, method_name)
191
+ else
192
+ scope.user_def_for(owner, method_name)
193
+ end
194
+ end
195
+
196
+ # True when the definition declares at least one overload and EVERY overload's return type is an
197
+ # instance of `base_klass` (`RBS::Types::Bases::Any` for admission 1, `...::Void` for the leaf).
198
+ # An `UntypedFunction` (`(?) -> T`) still responds to `return_type`, so it is handled the same way.
199
+ def every_overload_return?(definition, base_klass)
200
+ method_types = definition.method_types
201
+ return false if method_types.empty?
202
+
203
+ method_types.all? do |method_type|
204
+ fun = method_type.type
205
+ fun.respond_to?(:return_type) && fun.return_type.is_a?(base_klass)
206
+ end
207
+ end
208
+
209
+ def memo_bucket
210
+ generation = scope.run_generation || scope.discovered_def_nodes
211
+ slot = Thread.current[MEMO_KEY]
212
+ unless slot && slot[0].equal?(generation)
213
+ slot = [generation, {}.compare_by_identity]
214
+ Thread.current[MEMO_KEY] = slot
215
+ end
216
+ slot[1]
217
+ end
218
+ end
219
+ end
220
+ end
@@ -8,6 +8,7 @@ require_relative "../environment"
8
8
  require_relative "../reflection"
9
9
  require_relative "../scope"
10
10
  require_relative "../source/node_locator"
11
+ require_relative "../source/node_children"
11
12
  require_relative "../inference/scope_indexer"
12
13
  require_relative "../type/nominal"
13
14
  require_relative "../type/singleton"
@@ -187,7 +188,7 @@ module Rigor
187
188
  n.location.start_offset <= symbol_offset && symbol_offset <= n.location.end_offset
188
189
  result = n
189
190
  end
190
- n.compact_child_nodes.each(&walk)
191
+ n.rigor_each_child(&walk)
191
192
  end
192
193
  walk.call(root)
193
194
  result
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "uri"
6
6
  require_relative "buffer_resolution"
7
+ require_relative "../source/node_children"
7
8
 
8
9
  module Rigor
9
10
  module LanguageServer
@@ -67,7 +68,7 @@ module Rigor
67
68
  when Prism::DefNode
68
69
  block.call(def_symbol(node, in_namespace: in_namespace))
69
70
  else
70
- node.compact_child_nodes.each do |child|
71
+ node.rigor_each_child do |child|
71
72
  each_decl(child, in_namespace: in_namespace, &block)
72
73
  end
73
74
  end
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "uri"
6
6
  require_relative "buffer_resolution"
7
+ require_relative "../source/node_children"
7
8
 
8
9
  module Rigor
9
10
  module LanguageServer
@@ -48,7 +49,7 @@ module Rigor
48
49
  Prism::BlockNode
49
50
  add_range(node, ranges)
50
51
  end
51
- node.compact_child_nodes.each { |child| walk(child, ranges) }
52
+ node.rigor_each_child { |child| walk(child, ranges) }
52
53
  end
53
54
 
54
55
  def add_range(node, ranges)
@@ -4,6 +4,7 @@ require "prism"
4
4
 
5
5
  require_relative "uri"
6
6
  require_relative "buffer_resolution"
7
+ require_relative "../source/node_children"
7
8
 
8
9
  module Rigor
9
10
  module LanguageServer
@@ -48,7 +49,7 @@ module Rigor
48
49
  return chain unless node.location && offset_in?(node.location, offset)
49
50
 
50
51
  chain << node
51
- node.compact_child_nodes.each { |child| ancestor_chain(child, offset, chain) }
52
+ node.rigor_each_child { |child| ancestor_chain(child, offset, chain) }
52
53
  chain
53
54
  end
54
55
 
@@ -8,6 +8,7 @@ require_relative "../environment"
8
8
  require_relative "../reflection"
9
9
  require_relative "../scope"
10
10
  require_relative "../source/node_locator"
11
+ require_relative "../source/node_children"
11
12
  require_relative "../inference/scope_indexer"
12
13
  require_relative "../type/nominal"
13
14
  require_relative "../type/singleton"
@@ -99,7 +100,7 @@ module Rigor
99
100
  if n.is_a?(Prism::CallNode) && n.arguments && offset_in?(n.arguments.location, cursor_offset)
100
101
  result = n # Innermost-wins because we keep walking children.
101
102
  end
102
- n.compact_child_nodes.each(&walk)
103
+ n.rigor_each_child(&walk)
103
104
  end
104
105
  walk.call(root)
105
106
  result
@@ -390,8 +390,8 @@ module Rigor
390
390
  # the fact slot.
391
391
  #
392
392
  # Renamed from `type_specifier` (ADR-80): the old name read as a parallel to `dynamic_return` (a
393
- # type) when it actually returns facts. {.type_specifier} survives as a deprecating alias through
394
- # 0.2.x and is removed in 0.3.0.
393
+ # type) when it actually returns facts. The old verb was a deprecating alias through 0.2.x and is
394
+ # gone in 0.3.0 `narrowing_facts` is the only spelling.
395
395
  def narrowing_facts(methods:, &block)
396
396
  raise ArgumentError, "Plugin::Base.narrowing_facts requires a block body" if block.nil?
397
397
  unless methods.is_a?(Array) && !methods.empty? &&
@@ -401,30 +401,17 @@ module Rigor
401
401
  "got #{methods.inspect}"
402
402
  end
403
403
 
404
- @type_specifiers ||= []
405
- @type_specifiers << { methods: methods.map(&:to_sym).freeze, block: block }.freeze
404
+ @narrowing_facts_rules ||= []
405
+ @narrowing_facts_rules << { methods: methods.map(&:to_sym).freeze, block: block }.freeze
406
406
  nil
407
407
  end
408
408
 
409
- # DEPRECATED (ADR-80) renamed to {.narrowing_facts}. This hook supplies post-return narrowing
410
- # *facts*, not a type; the old name misleads by parallel with {.dynamic_return}. Retained as a
411
- # warning-emitting alias through 0.2.x; REMOVED in 0.3.0. Migrate `type_specifier methods: …` →
412
- # `narrowing_facts methods: …`.
413
- def type_specifier(methods:, &)
414
- unless @type_specifier_deprecation_warned
415
- @type_specifier_deprecation_warned = true
416
- warn("[rigor] Plugin::Base.type_specifier is deprecated (ADR-80) and will be " \
417
- "removed in Rigor 0.3.0; rename it to `narrowing_facts`. (#{name})")
418
- end
419
- narrowing_facts(methods:, &)
420
- end
421
-
422
- # Frozen snapshot of the declared type-specifier rules. Memoised for the same reason as
409
+ # Frozen snapshot of the declared narrowing-facts rules. Memoised for the same reason as
423
410
  # {dynamic_returns} — consulted per plugin per dispatch, over an array fixed at class-definition
424
411
  # time.
425
412
  # rubocop:disable Naming/MemoizedInstanceVariableName -- see dynamic_returns
426
- def type_specifiers
427
- @type_specifiers_snapshot ||= (@type_specifiers || []).dup.freeze
413
+ def narrowing_facts_rules
414
+ @narrowing_facts_rules_snapshot ||= (@narrowing_facts_rules || []).dup.freeze
428
415
  end
429
416
  # rubocop:enable Naming/MemoizedInstanceVariableName
430
417
  end
@@ -558,8 +545,8 @@ module Rigor
558
545
  # ADR-37 slice 2 — the post-return narrowing facts contributed by this plugin's {.narrowing_facts}
559
546
  # rules for a call. The engine calls this from `StatementEvaluator`; a rule fires only when
560
547
  # `call_node.name` is one of its declared `methods:`. Failures isolate to [].
561
- def type_specifier_facts(call_node:, scope:)
562
- rules = self.class.type_specifiers
548
+ def narrowing_facts_for(call_node:, scope:)
549
+ rules = self.class.narrowing_facts_rules
563
550
  return [] if rules.empty? || !call_node.respond_to?(:name)
564
551
 
565
552
  name = call_node.name
@@ -18,9 +18,12 @@ module Rigor
18
18
  # rules are ingested by *statically parsing* `config/initializers/inflections.rb` in a later slice, never
19
19
  # by executing it.)
20
20
  #
21
- # **Absence is silence, never a guess.** When `ActiveSupport::Inflector` cannot be loaded (a
22
- # misconfiguration the consuming plugins declare it as a dependency, so it is present in practice), the
23
- # inflection methods raise {Unavailable} rather than approximate. That raise propagates to the caller's
21
+ # **Absence is silence, never a guess.** ActiveSupport is resolved from Rigor's host gem environment
22
+ # first, then from the analyzed project's own bundler install tree ({Isolation.target_bundle_root},
23
+ # ADR-90) a Rails project always carries its locked activesupport on disk, and that copy is the
24
+ # higher-fidelity source of inflection rules anyway. When neither resolves (a standalone install
25
+ # analyzing a project whose bundle is not installed), the inflection methods raise {Unavailable}
26
+ # rather than approximate. That raise propagates to the caller's
24
27
  # per-plugin rescue boundary, so the inflection-dependent check degrades to **no diagnostics** — reduced
25
28
  # coverage, never a wrong fact. A consumer that wants to fail cleanly up front can gate on {available?}
26
29
  # and emit a single load-error.
@@ -39,6 +42,12 @@ module Rigor
39
42
  FEATURE = "active_support/inflector"
40
43
  RECEIVER = "ActiveSupport::Inflector"
41
44
 
45
+ # The bundled plugins whose checks consume this helper (each README documents the dependency). Used
46
+ # by `rigor plugins` (ADR-90) to probe {available?} at activation time — so a standalone install
47
+ # where inflection silently degrades reports the degradation instead of an unqualified `[OK]`. When
48
+ # a new plugin adopts the helper, add its manifest id here.
49
+ CONSUMER_PLUGIN_IDS = %w[actionmailer actionpack activerecord factorybot rails-routes].freeze
50
+
42
51
  # Raised when `ActiveSupport::Inflector` is required for an inflection but cannot be loaded. Caught by
43
52
  # the per-plugin isolation boundary, so it surfaces as "this plugin produced no diagnostics" rather than
44
53
  # a wrong inflection.
@@ -97,8 +97,12 @@ module Rigor
97
97
  private
98
98
 
99
99
  def record_file_entry(path, contents)
100
+ # ADR-87 WD1 — the boundary descriptor is validation-only (it never keys a cache), so a plugin-read
101
+ # file rides the stat-then-digest `:stat` tier: a warm run stat-checks the (on a Rails monorepo,
102
+ # 30k+ file) plugin-read set instead of re-hashing it. The digest is taken from the in-memory content
103
+ # we just read; `FileEntry.stat` stats the same path to pack the tuple.
100
104
  digest = Digest::SHA256.hexdigest(contents)
101
- entry = Cache::Descriptor::FileEntry.new(path: path, comparator: :digest, value: digest)
105
+ entry = Cache::Descriptor::FileEntry.stat(path: path, digest: digest)
102
106
  @mutex.synchronize { @file_entries[path] = entry }
103
107
  end
104
108
 
@@ -30,6 +30,65 @@ module Rigor
30
30
 
31
31
  module_function
32
32
 
33
+ # The analyzed project's resolved bundler install root (e.g. `<project>/vendor/bundle`), or nil.
34
+ # Set from the same `bundler.*` resolution that feeds bundle sig discovery (O4 / ADR-82 WD9) — by
35
+ # the runner's pre-passes BEFORE any plugin `#prepare` runs, by `Environment.for_project` (which
36
+ # also covers pool workers), and by `rigor plugins`' probe. When a target library cannot be
37
+ # required from Rigor's own gem
38
+ # environment (the standalone `gem install rigortype` case — activesupport is deliberately NOT a
39
+ # runtime dependency), {require_with_target_bundle} falls back to requiring it from this bundle:
40
+ # the analyzed Rails project always carries its own activesupport on disk, and loading the
41
+ # project's locked copy is the higher-fidelity source of inflection rules anyway (ADR-79).
42
+ def target_bundle_root
43
+ @target_bundle_root
44
+ end
45
+
46
+ def target_bundle_root=(root)
47
+ @target_bundle_root = root.nil? ? nil : File.expand_path(root.to_s)
48
+ end
49
+
50
+ # Requires `feature`, falling back to the analyzed project's bundler install tree. The fallback
51
+ # appends every bundle gem's `full_require_paths` (from its RubyGems-generated `specifications/`
52
+ # gemspec — the gem's own metadata, so nonstandard require paths like concurrent-ruby's
53
+ # `lib/concurrent-ruby` resolve correctly) to `$LOAD_PATH` and retries. `$LOAD_PATH` is appended,
54
+ # never prepended, and only on a failed require: Rigor's own activated gems keep precedence, and a
55
+ # host environment that carries the gem never consults the bundle. (`Gem.paths` augmentation was
56
+ # rejected — Bundler-locked processes silently ignore it.) Under the default `process` strategy the
57
+ # mutation happens inside the forked worker only; under `none`/Direct it lands in the main space,
58
+ # which is what that strategy means (documented tradeoff — the invoked library is trusted + pure).
59
+ def require_with_target_bundle(feature, bundle_root)
60
+ require feature
61
+ rescue ::LoadError
62
+ added = bundle_require_paths(bundle_root) - $LOAD_PATH
63
+ raise if added.empty?
64
+
65
+ $LOAD_PATH.concat(added)
66
+ require feature
67
+ end
68
+
69
+ # Every bundle gem's require paths, newest version per gem name. Loading a `specifications/*.gemspec`
70
+ # evaluates RubyGems-generated metadata of an installed third-party gem — the same trust level as
71
+ # requiring the gem, which is what the caller is about to do.
72
+ def bundle_require_paths(bundle_root)
73
+ bundle_gem_dirs(bundle_root).flat_map do |gem_home|
74
+ specs = Dir.glob(File.join(gem_home, "specifications", "*.gemspec"))
75
+ .filter_map { |file| Gem::Specification.load(file) }
76
+ specs.group_by(&:name)
77
+ .flat_map { |_, versions| versions.max_by(&:version).full_require_paths }
78
+ end
79
+ end
80
+
81
+ # The RubyGems-shaped directories under a bundler install root: bundler nests them as
82
+ # `<root>/ruby/<ruby-version>/`; a `BUNDLE_PATH` may also point directly at such a directory. Only
83
+ # directories that actually carry a `specifications/` index are returned.
84
+ def bundle_gem_dirs(bundle_root)
85
+ return [] if bundle_root.nil? || bundle_root.to_s.empty?
86
+
87
+ root = bundle_root.to_s
88
+ (Dir.glob(File.join(root, "ruby", "*")) + [root])
89
+ .select { |dir| File.directory?(File.join(dir, "specifications")) }
90
+ end
91
+
33
92
  # The default strategy. `process` (a crash-contained forked worker) is the default: it isolates the
34
93
  # target library's monkey-patches + crashes from Rigor with no in-process contamination, and forks a
35
94
  # single persistent worker (not one per call). It falls back to `none` where fork is unavailable (see
@@ -68,10 +127,15 @@ module Rigor
68
127
  module Direct
69
128
  module_function
70
129
 
130
+ # NOTE: rescue classes are `::`-qualified throughout this file: `Rigor::Plugin::LoadError` (the
131
+ # plugin-loading error) shadows the global `LoadError` in this lexical scope once plugin machinery
132
+ # is loaded, so a bare `rescue LoadError` matches the WRONG class — the real `::LoadError` (a
133
+ # ScriptError, not a StandardError) then escapes, kills the worker, and surfaces as an opaque
134
+ # EOFError instead of the clean decline (the standalone-install regression of 2026-07-16).
71
135
  def call(feature:, receiver:, method:, args:)
72
- require feature
136
+ Isolation.require_with_target_bundle(feature, Isolation.target_bundle_root)
73
137
  Object.const_get(receiver).public_send(method, *args)
74
- rescue LoadError, NameError => e
138
+ rescue ::LoadError, ::NameError => e
75
139
  raise Unavailable, "#{receiver} could not be loaded (#{e.class}: #{e.message})"
76
140
  end
77
141
  end
@@ -109,13 +173,13 @@ module Rigor
109
173
  def call(feature:, receiver:, method:, args:)
110
174
  raise Unavailable, "process isolation unavailable: fork is not supported" unless available?
111
175
 
112
- status, value = exchange([feature, receiver, method, args])
176
+ status, value = exchange([feature, receiver, method, args, Isolation.target_bundle_root])
113
177
  raise Unavailable, "process isolation worker error: #{value}" if status == :error
114
178
 
115
179
  value
116
180
  rescue Unavailable
117
181
  raise
118
- rescue StandardError => e
182
+ rescue ::StandardError => e
119
183
  # A dead worker surfaces as EOFError (Marshal.load) or Errno::EPIPE (Marshal.dump) — both
120
184
  # StandardError. The crash is contained: the parent resets the worker (respawn next call) and
121
185
  # declines.
@@ -123,7 +187,9 @@ module Rigor
123
187
  raise Unavailable, "process isolation worker failed (#{e.class})"
124
188
  end
125
189
 
126
- # Sends one request to the persistent worker and reads its reply.
190
+ # Sends one request to the persistent worker and reads its reply. The request carries the
191
+ # target-bundle root so the fallback require (see {Isolation.require_with_target_bundle}) happens
192
+ # inside the worker — the parent's gem environment is never touched.
127
193
  def exchange(request)
128
194
  w = worker
129
195
  Marshal.dump(request, w[:req])
@@ -152,22 +218,26 @@ module Rigor
152
218
  end
153
219
  end
154
220
 
155
- # The child loop: read a `[feature, receiver, method, args]` request, require + call, and write
156
- # `[:ok, result]` or `[:error, message]`. EOF (parent gone) ends the loop.
221
+ # The child loop: read a `[feature, receiver, method, args, bundle_root]` request, require + call,
222
+ # and write `[:ok, result]` or `[:error, message]`. EOF (parent gone) ends the loop.
223
+ #
224
+ # The rescue is `::`-qualified (see {Direct.call}) and covers `::ScriptError` so a failed require
225
+ # (`::LoadError` is a ScriptError) replies with the clean `[:error, …]` decline instead of
226
+ # silently killing the worker — the parent would otherwise see only an opaque EOFError.
157
227
  def run_worker_loop(req_r, res_w)
158
228
  loop do
159
- feature, receiver, method, args = Marshal.load(req_r) # rubocop:disable Security/MarshalLoad -- parent input
229
+ feature, receiver, method, args, bundle_root = Marshal.load(req_r) # rubocop:disable Security/MarshalLoad -- parent input
160
230
  reply =
161
231
  begin
162
- require feature
232
+ Isolation.require_with_target_bundle(feature, bundle_root)
163
233
  [:ok, Object.const_get(receiver).public_send(method, *args)]
164
- rescue StandardError, LoadError => e
234
+ rescue ::StandardError, ::ScriptError => e
165
235
  [:error, "#{e.class}: #{e.message}"]
166
236
  end
167
237
  Marshal.dump(reply, res_w)
168
238
  res_w.flush
169
239
  end
170
- rescue EOFError
240
+ rescue ::EOFError
171
241
  # parent closed the request pipe — exit quietly
172
242
  ensure
173
243
  exit!(0)
@@ -11,6 +11,14 @@ module Rigor
11
11
  class LoadError < StandardError
12
12
  attr_reader :plugin_ref, :cause_class, :reason
13
13
 
14
+ # #194 slice 1 — the file `require` resolved the plugin gem to, when the require SUCCEEDED but the
15
+ # later configuration / instantiation step then failed. The loader stamps it in a rescue after a
16
+ # successful require (a require that failed outright never resolves a file and leaves this nil), so a
17
+ # config/init failure names the exact plugin copy it loaded from — the engine↔plugin version skew that
18
+ # made #194 a multi-round diagnosis. Consumed by the `plugin_loader.load-error` diagnostic message and
19
+ # the `rigor plugins` load-error row.
20
+ attr_accessor :resolved_path
21
+
14
22
  # ADR-9 slice 5 introduces two new reason codes alongside the implicit "load failure" used for require /
15
23
  # configuration / init failures:
16
24
  #
@@ -19,11 +27,12 @@ module Rigor
19
27
  # - `:dependency-cycle` — the consumes graph forms a cycle.
20
28
  #
21
29
  # Older callers omit `reason:` and the field defaults to nil (the legacy "load failure" envelope).
22
- def initialize(message, plugin_ref:, cause: nil, reason: nil)
30
+ def initialize(message, plugin_ref:, cause: nil, reason: nil, resolved_path: nil)
23
31
  super(message)
24
32
  @plugin_ref = plugin_ref
25
33
  @cause_class = cause&.class
26
34
  @reason = reason&.to_sym
35
+ @resolved_path = resolved_path
27
36
  end
28
37
  end
29
38
  end