rigortype 0.1.9 → 0.1.11

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 (158) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/rigor/analysis/baseline.rb +51 -15
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +57 -7
  9. data/lib/rigor/cli/baseline_command.rb +4 -3
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli.rb +88 -5
  15. data/lib/rigor/environment/rbs_loader.rb +46 -5
  16. data/lib/rigor/environment/reporters.rb +3 -2
  17. data/lib/rigor/environment.rb +159 -4
  18. data/lib/rigor/inference/def_return_typer.rb +98 -0
  19. data/lib/rigor/inference/expression_typer.rb +143 -12
  20. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
  21. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  22. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
  23. data/lib/rigor/inference/precision_scanner.rb +131 -0
  24. data/lib/rigor/inference/statement_evaluator.rb +26 -2
  25. data/lib/rigor/mcp/loop.rb +43 -0
  26. data/lib/rigor/mcp/server.rb +263 -0
  27. data/lib/rigor/mcp.rb +16 -0
  28. data/lib/rigor/plugin/base.rb +28 -5
  29. data/lib/rigor/plugin/manifest.rb +33 -5
  30. data/lib/rigor/plugin/registry.rb +21 -0
  31. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  32. data/lib/rigor/sig_gen/generator.rb +150 -75
  33. data/lib/rigor/type/combinator.rb +57 -0
  34. data/lib/rigor/version.rb +1 -1
  35. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  36. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  37. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  38. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  39. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  40. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
  41. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
  42. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
  43. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
  44. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  45. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
  46. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
  47. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
  49. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  50. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  51. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  54. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  58. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
  62. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  63. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  66. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  67. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  68. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  69. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
  70. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  71. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
  72. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  73. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  74. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  75. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  76. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  77. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  78. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  79. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  80. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  81. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  82. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  83. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  84. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  85. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  86. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  87. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  88. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  89. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  90. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  91. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  93. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  94. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  95. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  96. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  97. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  98. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  99. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  100. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  101. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  102. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  103. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  104. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  105. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  106. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  107. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
  108. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  109. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  110. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
  111. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  112. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
  113. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
  114. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
  115. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
  116. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  117. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  118. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  119. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  120. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  121. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  122. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  125. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  126. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  127. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  128. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  129. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  130. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  131. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  132. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  133. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  134. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  135. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  136. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  137. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  138. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  139. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  141. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  142. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  143. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  144. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  145. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  146. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  147. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  148. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  149. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  150. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  151. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  152. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  153. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  154. data/sig/rigor/analysis/baseline.rbs +39 -0
  155. data/sig/rigor/environment.rbs +3 -2
  156. data/sig/rigor/type.rbs +4 -0
  157. data/sig/rigor.rbs +2 -0
  158. metadata +180 -1
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "type_translator"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ class Sorbet < Rigor::Plugin::Base
10
+ # Slice 6 of ADR-11 — recognises `T.absurd(x)` calls and
11
+ # composes them with the engine's flow-sensitive
12
+ # narrowing. `T.absurd` asserts that a code branch is
13
+ # statically unreachable; it's the standard Sorbet idiom
14
+ # for case/when exhaustiveness:
15
+ #
16
+ # case x
17
+ # when A then ...
18
+ # when B then ...
19
+ # else
20
+ # T.absurd(x)
21
+ # end
22
+ #
23
+ # If every case has been handled, `x` at the `else` branch
24
+ # has been narrowed to `T.noreturn` (Rigor's `Type::Bot`)
25
+ # and the assertion holds. If the user forgot a case, `x`
26
+ # narrows to whatever's left and the assertion is wrong —
27
+ # we surface that mistake as `plugin.sorbet.absurd-reachable`.
28
+ #
29
+ # ## Two-phase mechanism
30
+ #
31
+ # The recogniser is invoked from `flow_contribution_for`
32
+ # where the per-node `scope:` carries the proper narrowing
33
+ # context. It returns:
34
+ #
35
+ # - A `FlowContribution` with `return_type: bot` and
36
+ # `exceptional: :raises` regardless of reachability
37
+ # (faithful to `T.absurd`'s runtime behaviour: it always
38
+ # raises). This lets the engine's existing flow analysis
39
+ # treat code after `T.absurd` as unreachable, matching
40
+ # what users of Sorbet expect.
41
+ # - When the branch is REACHABLE (the discriminant's type
42
+ # isn't `bot`), the recogniser also records the call
43
+ # node in a per-plugin set. The plugin's
44
+ # `diagnostics_for_file` later walks the AST for
45
+ # `T.absurd` calls and emits a
46
+ # `plugin.sorbet.absurd-reachable` warning at every
47
+ # call_node whose object identity matches the recorded
48
+ # set. We rely on the runner only parsing each file
49
+ # once per run, so the same Prism node object is seen
50
+ # in both `flow_contribution_for` and
51
+ # `diagnostics_for_file`.
52
+ module AbsurdRecognizer
53
+ # @param call_node [Prism::CallNode]
54
+ # @return [Boolean] true when `call_node` is `T.absurd(x)`.
55
+ def self.absurd_call?(call_node)
56
+ return false unless call_node.is_a?(Prism::CallNode)
57
+ return false unless call_node.name == :absurd
58
+ return false unless TypeTranslator.sorbet_t_namespaced?(call_node.receiver)
59
+
60
+ # Slice 6 only handles single-argument `T.absurd(x)`;
61
+ # no-arg / multi-arg shapes are syntax errors at
62
+ # Sorbet's level too.
63
+ arguments = call_node.arguments&.arguments
64
+ arguments&.size == 1
65
+ end
66
+
67
+ # @param call_node [Prism::CallNode]
68
+ # @param scope [Rigor::Scope, nil]
69
+ # @return [Boolean] true when the discriminant has been
70
+ # narrowed to `bot` (the branch is unreachable, so
71
+ # `T.absurd` is correct). The caller suppresses the
72
+ # `absurd-reachable` diagnostic in this case.
73
+ def self.exhaustive?(call_node, scope)
74
+ return false if scope.nil?
75
+
76
+ arg = call_node.arguments.arguments.first
77
+ arg_type = scope.type_of(arg)
78
+ arg_type.equal?(Rigor::Type::Bot.instance) || arg_type.is_a?(Rigor::Type::Bot)
79
+ rescue StandardError
80
+ # On synthetic / unrecognised nodes the typer may
81
+ # raise; treat as "can't prove unreachable" so the
82
+ # diagnostic fires conservatively.
83
+ false
84
+ end
85
+
86
+ # The contribution every `T.absurd` call gets,
87
+ # regardless of static reachability — `T.absurd` raises
88
+ # at runtime, so its return type is `bot` and the call
89
+ # is exceptional. This lets the engine's flow analysis
90
+ # treat code after the call as unreachable (no
91
+ # `flow.unreachable-branch` from us; that's an engine
92
+ # rule that consults the same effect lattice).
93
+ def self.contribution(call_node, plugin_id)
94
+ Rigor::FlowContribution.new(
95
+ return_type: Rigor::Type::Combinator.bot,
96
+ exceptional: :raises,
97
+ provenance: Rigor::FlowContribution::Provenance.new(
98
+ source_family: "plugin.#{plugin_id}",
99
+ plugin_id: plugin_id,
100
+ node: call_node,
101
+ descriptor: nil
102
+ )
103
+ )
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "type_translator"
6
+
7
+ module Rigor
8
+ module Plugin
9
+ class Sorbet < Rigor::Plugin::Base
10
+ # Lifts Sorbet's type-assertion calls (`T.let`, `T.cast`,
11
+ # `T.must`, `T.must_because`, `T.unsafe`, `T.reveal_type`)
12
+ # into `FlowContribution` return-type contributions.
13
+ # ADR-11 slice 2 covered the original four; the
14
+ # `must_because` / `reveal_type` light follow-up extends
15
+ # the same module rather than splitting into a parallel
16
+ # recogniser.
17
+ #
18
+ # | Sorbet form | Contribution |
19
+ # | ---------------------------- | --------------------------------------- |
20
+ # | `T.let(expr, T)` | return type ← translated `T` |
21
+ # | `T.cast(expr, T)` | return type ← translated `T` |
22
+ # | `T.must(expr)` | return type ← `inferred(expr) - nil` |
23
+ # | `T.must_because(expr, "..")` | return type ← `inferred(expr) - nil` |
24
+ # | `T.unsafe(x)` | return type ← `Dynamic[top]` |
25
+ # | `T.reveal_type(expr)` | return type ← `inferred(expr)` (passes through) |
26
+ # | `T.assert_type!(expr, T)` | return type ← translated `T` + static subtype check |
27
+ # | `T.bind(self, T)` | return type ← `Constant[nil]` + post_return_fact narrowing self to translated `T` |
28
+ #
29
+ # The Sorbet runtime's `T.let` / `T.cast` actually return
30
+ # the inner expression unchanged at runtime; their job is
31
+ # purely to *assert* a static type. From Rigor's static
32
+ # perspective the simplest faithful translation is "the
33
+ # call's return type IS the asserted type" — the call
34
+ # site's downstream uses see that type. This matches what
35
+ # `%a{rigor:v1:assert: x is T}` would do for an assignment
36
+ # in the surrounding scope.
37
+ #
38
+ # `T.must_because` is `T.must` with a second-argument
39
+ # string explanation. The static behaviour is identical to
40
+ # `T.must` — strip `nil` from the inferred type — so the
41
+ # recogniser dispatches through the same path.
42
+ #
43
+ # `T.reveal_type` is "diagnostic-only" in Sorbet: it
44
+ # passes the value through unchanged at runtime AND
45
+ # surfaces the inferred static type as a build-time
46
+ # message. The recogniser contributes the inferred type
47
+ # (so chained call sites still resolve as if the
48
+ # `T.reveal_type` wrapper weren't there); the plugin's
49
+ # `diagnostics_for_file` hook surfaces the
50
+ # `plugin.sorbet.reveal-type` `:info` message for human
51
+ # consumption.
52
+ #
53
+ # `T.bind(self, T)` is recognised as block-scope self
54
+ # narrowing. The recogniser returns a contribution whose
55
+ # `post_return_facts` carries a `Fact(target_kind: :self)`
56
+ # so the engine's `apply_self_post_return_fact` narrows
57
+ # `scope.self_type` for the surrounding scope (in a block
58
+ # body, the rest of the block). The first argument MUST be
59
+ # a literal `Prism::SelfNode` — Sorbet rejects other
60
+ # receivers and the recogniser mirrors that. The runtime
61
+ # call returns nil, so the static return type is
62
+ # `Constant[nil]`.
63
+ module AssertionRecognizer
64
+ # Method names this recogniser claims as Sorbet
65
+ # assertions. The plugin checks call sites against this
66
+ # set before any catalog lookup so a `T.let` call
67
+ # inside an analysed file always resolves through this
68
+ # module.
69
+ SORBET_ASSERTIONS = %i[let cast must must_because unsafe reveal_type assert_type! bind].freeze
70
+
71
+ module_function
72
+
73
+ # @param call_node [Prism::CallNode]
74
+ # @param scope [Rigor::Scope]
75
+ # @param plugin_id [String] used for the contribution's
76
+ # `provenance.source_family`.
77
+ # @return [Rigor::FlowContribution, nil]
78
+ def recognize(call_node:, scope:, plugin_id:)
79
+ return nil unless TypeTranslator.sorbet_t_namespaced?(call_node.receiver)
80
+ return nil unless SORBET_ASSERTIONS.include?(call_node.name)
81
+
82
+ return recognize_bind(call_node, plugin_id) if call_node.name == :bind
83
+
84
+ return_type = return_type_for(call_node, scope)
85
+ return nil if return_type.nil?
86
+
87
+ contribution(call_node, return_type, plugin_id)
88
+ end
89
+
90
+ def return_type_for(call_node, scope)
91
+ case call_node.name
92
+ when :let, :cast then resolve_typed_assertion(call_node)
93
+ when :must, :must_because then resolve_must(call_node, scope)
94
+ when :unsafe then Rigor::Type::Combinator.untyped
95
+ when :reveal_type then resolve_reveal_type(call_node, scope)
96
+ when :assert_type! then resolve_typed_assertion(call_node)
97
+ end
98
+ end
99
+
100
+ # `T.bind(self, T)` recognition. Sorbet rejects any
101
+ # non-`self` receiver argument, and the recogniser
102
+ # mirrors that — calls like `T.bind(other, X)` fall
103
+ # through silently. The contribution carries:
104
+ #
105
+ # - `return_type: Constant[nil]` — Sorbet's runtime
106
+ # `T.bind` returns nil; chained calls would be a
107
+ # bug, but the typing stays accurate.
108
+ # - `post_return_facts: [Fact(target_kind: :self,
109
+ # type: T)]` — the engine's
110
+ # `apply_self_post_return_fact` narrows
111
+ # `scope.self_type` for the surrounding scope. In a
112
+ # block body, that scope is the block's own, so the
113
+ # narrowing applies to the rest of the block —
114
+ # matching Sorbet's documented contract.
115
+ def recognize_bind(call_node, plugin_id)
116
+ first_arg = nth_argument(call_node, 0)
117
+ return nil unless first_arg.is_a?(Prism::SelfNode)
118
+
119
+ type_arg = nth_argument(call_node, 1)
120
+ return nil if type_arg.nil?
121
+
122
+ asserted = TypeTranslator.translate(type_arg)
123
+ return nil if asserted.nil?
124
+
125
+ fact = Rigor::FlowContribution::Fact.new(
126
+ target_kind: :self, target_name: :self, type: asserted
127
+ )
128
+ Rigor::FlowContribution.new(
129
+ return_type: Rigor::Type::Combinator.constant_of(nil),
130
+ post_return_facts: [fact],
131
+ provenance: Rigor::FlowContribution::Provenance.new(
132
+ source_family: "plugin.#{plugin_id}",
133
+ plugin_id: plugin_id,
134
+ node: call_node,
135
+ descriptor: nil
136
+ )
137
+ )
138
+ end
139
+
140
+ # `T.assert_type!(expr, T)` shares the typed-assertion
141
+ # contribution shape with `T.cast` (return is the
142
+ # asserted type), so the recogniser delegates the
143
+ # return-type half through `resolve_typed_assertion`.
144
+ # The static subtype check that distinguishes
145
+ # `assert_type!` from `cast` lives in the plugin's
146
+ # `diagnostics_for_file` hook (mirroring the
147
+ # absurd-recognizer pattern: record the call here, emit
148
+ # the diagnostic from the per-file walker).
149
+ def assert_type_check(call_node, scope)
150
+ return nil if scope.nil?
151
+
152
+ inner = nth_argument(call_node, 0)
153
+ asserted_node = nth_argument(call_node, 1)
154
+ return nil if inner.nil? || asserted_node.nil?
155
+
156
+ asserted_type = TypeTranslator.translate(asserted_node)
157
+ return nil if asserted_type.nil?
158
+
159
+ inferred = scope.type_of(inner)
160
+ [inferred, asserted_type]
161
+ rescue StandardError
162
+ nil
163
+ end
164
+
165
+ # `T.reveal_type(expr)` returns `expr` unchanged at
166
+ # runtime; the Sorbet-side semantics is "make the
167
+ # inferred static type visible to the user." The
168
+ # contribution mirrors `T.must` minus the nil-stripping:
169
+ # the call's return type is the inner expression's
170
+ # inferred type. The companion diagnostic is emitted by
171
+ # the plugin's `diagnostics_for_file` hook through
172
+ # {RevealTypeRecognizer}; the recogniser here is
173
+ # contribution-only.
174
+ def resolve_reveal_type(call_node, scope)
175
+ inner = nth_argument(call_node, 0)
176
+ return Rigor::Type::Combinator.untyped if inner.nil? || scope.nil?
177
+
178
+ inner_type = scope.type_of(inner)
179
+ inner_type || Rigor::Type::Combinator.untyped
180
+ rescue StandardError
181
+ # Synthetic / virtual nodes can raise from
182
+ # `scope.type_of`; degrade gracefully so the dispatcher
183
+ # can still proceed with a benign untyped envelope.
184
+ Rigor::Type::Combinator.untyped
185
+ end
186
+
187
+ # `T.let(expr, T)` and `T.cast(expr, T)` share the same
188
+ # 2-argument shape: `arguments[1]` is the type
189
+ # expression. The first argument is opaque to slice 2 —
190
+ # we don't try to verify it at runtime; that's `srb tc`'s
191
+ # job and is out of scope per ADR-11.
192
+ def resolve_typed_assertion(call_node)
193
+ type_arg = nth_argument(call_node, 1)
194
+ return nil if type_arg.nil?
195
+
196
+ TypeTranslator.translate(type_arg)
197
+ end
198
+
199
+ # `T.must(expr)` strips `nil` from `expr`'s inferred
200
+ # type. The call's target type is therefore
201
+ # `inferred(expr) - Constant[nil]`. Falls back to the
202
+ # untyped envelope when the inferred shape is itself
203
+ # `Dynamic[top]` or when no scope is available
204
+ # (synthetic / virtual-node call sites).
205
+ def resolve_must(call_node, scope)
206
+ inner = nth_argument(call_node, 0)
207
+ return nil if inner.nil? || scope.nil?
208
+
209
+ inner_type = scope.type_of(inner)
210
+ return Rigor::Type::Combinator.untyped if inner_type.nil?
211
+
212
+ strip_nil(inner_type)
213
+ rescue StandardError
214
+ # `scope.type_of` may raise on synthetic nodes; degrade
215
+ # to "no contribution" rather than crash the dispatcher.
216
+ nil
217
+ end
218
+
219
+ # Removes `nil` (`Constant[nil]`) from `type` using the
220
+ # `Difference` carrier. Idempotent on shapes that don't
221
+ # contain nil — the resulting `Difference[base, removed]`
222
+ # collapses to `base` if `base` already excludes the
223
+ # removed value, but the simple form here is good enough
224
+ # for slice 2; the precise normalisation lands when the
225
+ # Difference carrier gets full algebraic support.
226
+ def strip_nil(type)
227
+ Rigor::Type::Combinator.difference(
228
+ type, Rigor::Type::Combinator.constant_of(nil)
229
+ )
230
+ end
231
+
232
+ def nth_argument(call_node, index)
233
+ call_node.arguments&.arguments&.[](index)
234
+ end
235
+
236
+ def contribution(call_node, return_type, plugin_id)
237
+ Rigor::FlowContribution.new(
238
+ return_type: return_type,
239
+ provenance: Rigor::FlowContribution::Provenance.new(
240
+ source_family: "plugin.#{plugin_id}",
241
+ plugin_id: plugin_id,
242
+ node: call_node,
243
+ descriptor: nil
244
+ )
245
+ )
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class Sorbet < Rigor::Plugin::Base
6
+ # Per-run table of method signatures keyed by the
7
+ # `(class_name, method_name, kind)` triple. Built by
8
+ # {CatalogWalker} during the plugin's lazy pre-walk; read
9
+ # by {Sorbet#flow_contribution_for} at every call site.
10
+ #
11
+ # The catalog is mutable while it is being built, then
12
+ # frozen via {#freeze!} before the first read. Construction
13
+ # mutability is intentional — slice 1 builds the catalog
14
+ # incrementally as the walker visits each project file —
15
+ # but consumers MUST treat the catalog as read-only.
16
+ class Catalog
17
+ # Frozen empty bucket reused for classes that have no
18
+ # recorded mixins. Avoids allocating a fresh Hash on
19
+ # every `mixins_for` query.
20
+ EMPTY_MIXINS = { include: [].freeze, extend: [].freeze }.freeze
21
+
22
+ def initialize
23
+ @entries = {}
24
+ # ADR-11 slice 8 — per-class mixin declarations
25
+ # collected by `CatalogWalker`. Lookup-time chain
26
+ # traversal lifts sigs declared on a mixed-in
27
+ # module to call sites on the host class.
28
+ @mixins = {}
29
+ @frozen_after_build = false
30
+ end
31
+
32
+ # @param signature [MethodSignature]
33
+ def record(signature)
34
+ raise "Catalog already finalised" if @frozen_after_build
35
+
36
+ key = key_for(signature.class_name, signature.method_name, signature.kind)
37
+ @entries[key] = signature
38
+ end
39
+
40
+ # @param class_name [String] the class / module that
41
+ # carries the mixin (`class Post; include Foo; end`
42
+ # records under `"Post"`).
43
+ # @param kind [:include, :extend]
44
+ # @param module_name [String] the textual name of the
45
+ # mixed-in module as it appeared at the include /
46
+ # extend site (`"Foo"`, `"Foo::Bar"`, `"::Foo"`).
47
+ def record_mixin(class_name:, kind:, module_name:)
48
+ raise "Catalog already finalised" if @frozen_after_build
49
+
50
+ bucket = (@mixins[class_name] ||= { include: [], extend: [] })
51
+ list = bucket[kind]
52
+ list << module_name unless list.include?(module_name)
53
+ end
54
+
55
+ def freeze!
56
+ @frozen_after_build = true
57
+ @entries.freeze
58
+ @mixins.each_value do |bucket|
59
+ bucket.each_value(&:freeze)
60
+ bucket.freeze
61
+ end
62
+ @mixins.freeze
63
+ freeze
64
+ end
65
+
66
+ # @return [MethodSignature, nil]
67
+ def lookup(class_name:, method_name:, kind:)
68
+ @entries[key_for(class_name, method_name, kind)]
69
+ end
70
+
71
+ # @param class_name [String]
72
+ # @return [Hash{Symbol => Array<String>}] frozen mapping
73
+ # `{ include: [...], extend: [...] }`. Returns
74
+ # {EMPTY_MIXINS} when no mixins were recorded.
75
+ def mixins_for(class_name)
76
+ @mixins[class_name] || EMPTY_MIXINS
77
+ end
78
+
79
+ def empty?
80
+ @entries.empty?
81
+ end
82
+
83
+ def size
84
+ @entries.size
85
+ end
86
+
87
+ private
88
+
89
+ def key_for(class_name, method_name, kind)
90
+ [class_name.to_s, method_name.to_sym, kind]
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "method_signature"
6
+ require_relative "sig_parser"
7
+
8
+ module Rigor
9
+ module Plugin
10
+ class Sorbet < Rigor::Plugin::Base
11
+ # Walks a parsed Prism program looking for
12
+ # `sig { ... }` / `sig do ... end` calls that immediately
13
+ # precede a `def` (or a `def self.foo` / `class << self;
14
+ # def foo; end`). Each recognised pair is parsed by
15
+ # {SigParser} and recorded in the {Catalog} under its
16
+ # qualified `(class_name, method_name, kind)` key.
17
+ #
18
+ # Anything we don't recognise (a stray `sig { ... }` not
19
+ # followed by a `def`, a `def` with no preceding `sig`,
20
+ # malformed sig blocks, etc.) is reported back to the
21
+ # caller through `parse_errors:` so the plugin can emit a
22
+ # `plugin.sorbet.parse-error` diagnostic. Walking is
23
+ # otherwise infallible — a bad sig block does not abort
24
+ # the catalog build for the rest of the file.
25
+ module CatalogWalker
26
+ # Detected error during walking. `kind` is one of:
27
+ # `:no_block` / `:empty_block` / `:missing_returns_or_void`
28
+ # / `:duplicate_sig` / `:dangling_sig`.
29
+ ParseError = Data.define(:kind, :node, :path)
30
+
31
+ module_function
32
+
33
+ # @param root [Prism::Node] the file's program node.
34
+ # @param catalog [Catalog] mutable; signatures are
35
+ # recorded into it.
36
+ # @param path [String] file path used for diagnostic
37
+ # provenance.
38
+ # @return [Array<ParseError>] errors observed during the
39
+ # walk; empty when the file is sig-clean.
40
+ def walk(root:, catalog:, path:)
41
+ state = State.new(catalog: catalog, path: path)
42
+ walk_node(root, state, lexical_path: [], in_singleton_class: false)
43
+ state.errors
44
+ end
45
+
46
+ State = Struct.new(:catalog, :path, :errors, keyword_init: true) do
47
+ def initialize(catalog:, path:)
48
+ super(catalog: catalog, path: path, errors: [])
49
+ end
50
+
51
+ def record_error(kind, node)
52
+ errors << ParseError.new(kind: kind, node: node, path: path)
53
+ end
54
+ end
55
+
56
+ def walk_node(node, state, lexical_path:, in_singleton_class:)
57
+ return unless node.is_a?(Prism::Node)
58
+
59
+ case node
60
+ when Prism::ClassNode, Prism::ModuleNode
61
+ descend_class_or_module(node, state, lexical_path)
62
+ when Prism::SingletonClassNode
63
+ descend_singleton_class(node, state, lexical_path)
64
+ when Prism::StatementsNode
65
+ walk_statements(node, state, lexical_path: lexical_path, in_singleton_class: in_singleton_class)
66
+ when Prism::DefNode
67
+ # A `def` not preceded by a `sig` is fine; we just
68
+ # don't record anything for it. The interesting case
69
+ # is in `walk_statements`, which pairs sig+def.
70
+ else
71
+ node.compact_child_nodes.each do |child|
72
+ walk_node(child, state, lexical_path: lexical_path, in_singleton_class: in_singleton_class)
73
+ end
74
+ end
75
+ end
76
+
77
+ def descend_class_or_module(node, state, lexical_path)
78
+ name = qualified_name_for(node.constant_path)
79
+ if name && node.body
80
+ child_prefix = lexical_path + [name]
81
+ walk_node(node.body, state, lexical_path: child_prefix, in_singleton_class: false)
82
+ elsif node.body
83
+ walk_node(node.body, state, lexical_path: lexical_path, in_singleton_class: false)
84
+ end
85
+ end
86
+
87
+ def descend_singleton_class(node, state, lexical_path)
88
+ if node.expression.is_a?(Prism::SelfNode) && node.body
89
+ walk_node(node.body, state, lexical_path: lexical_path, in_singleton_class: true)
90
+ elsif node.body
91
+ walk_node(node.body, state, lexical_path: lexical_path, in_singleton_class: false)
92
+ end
93
+ end
94
+
95
+ # The pair-finding loop. Walks a `StatementsNode`'s
96
+ # children left-to-right; when it encounters a `sig`
97
+ # call, it remembers it and consumes the very next
98
+ # `def` / `def self.foo` as the target. Anything between
99
+ # a sig and its def (a comment is fine — comments aren't
100
+ # AST nodes — but a method call would be a problem)
101
+ # leaves the sig dangling.
102
+ def walk_statements(statements, state, lexical_path:, in_singleton_class:)
103
+ pending_sig = nil
104
+
105
+ statements.body.each do |child|
106
+ if pending_sig && def_node?(child)
107
+ record_def_with_sig(child, pending_sig, state, lexical_path, in_singleton_class)
108
+ pending_sig = nil
109
+ elsif sig_call?(child)
110
+ state.record_error(:duplicate_sig, pending_sig) if pending_sig
111
+ pending_sig = child
112
+ else
113
+ if pending_sig
114
+ state.record_error(:dangling_sig, pending_sig)
115
+ pending_sig = nil
116
+ end
117
+ # ADR-11 slice 8 — record `include` / `extend`
118
+ # declarations alongside the regular walk so the
119
+ # plugin's chain lookup can lift sigs declared
120
+ # on a mixed-in module to the host class.
121
+ record_mixin_call(child, state, lexical_path) if mixin_call?(child) && !in_singleton_class
122
+ walk_node(child, state, lexical_path: lexical_path, in_singleton_class: in_singleton_class)
123
+ end
124
+ end
125
+
126
+ state.record_error(:dangling_sig, pending_sig) if pending_sig
127
+ end
128
+
129
+ # `include Foo` / `extend Foo` / `include Foo, Bar` —
130
+ # the `include` and `extend` mixin macros that Tapioca-
131
+ # generated DSL RBIs depend on. Recognised when the
132
+ # call has no explicit receiver (top-level inside a
133
+ # class body) and every argument is a constant
134
+ # reference.
135
+ def mixin_call?(node)
136
+ return false unless node.is_a?(Prism::CallNode)
137
+ return false unless %i[include extend].include?(node.name)
138
+ return false unless node.receiver.nil?
139
+
140
+ args = node.arguments&.arguments
141
+ return false if args.nil? || args.empty?
142
+
143
+ args.all? do |arg|
144
+ arg.is_a?(Prism::ConstantReadNode) || arg.is_a?(Prism::ConstantPathNode)
145
+ end
146
+ end
147
+
148
+ def record_mixin_call(node, state, lexical_path)
149
+ return if lexical_path.empty?
150
+
151
+ class_name = lexical_path.join("::")
152
+ kind = node.name == :include ? :include : :extend
153
+ (node.arguments&.arguments || []).each do |arg|
154
+ module_name = qualified_name_for(arg)
155
+ next if module_name.nil?
156
+
157
+ state.catalog.record_mixin(
158
+ class_name: class_name, kind: kind, module_name: module_name
159
+ )
160
+ end
161
+ end
162
+
163
+ def sig_call?(node)
164
+ node.is_a?(Prism::CallNode) &&
165
+ node.name == :sig &&
166
+ node.receiver.nil? &&
167
+ !node.block.nil?
168
+ end
169
+
170
+ def def_node?(node)
171
+ node.is_a?(Prism::DefNode)
172
+ end
173
+
174
+ def record_def_with_sig(def_node, sig_call, state, lexical_path, in_singleton_class)
175
+ parsed = SigParser.parse(sig_call)
176
+ if parsed.is_a?(SigParser::ParseError)
177
+ state.record_error(parsed.reason, sig_call)
178
+ return
179
+ end
180
+
181
+ class_name = lexical_path.empty? ? "Object" : lexical_path.join("::")
182
+ kind = singleton_method?(def_node, in_singleton_class) ? :singleton : :instance
183
+ catalog_record(state.catalog, class_name, def_node.name, kind, parsed)
184
+ end
185
+
186
+ def catalog_record(catalog, class_name, method_name, kind, parsed)
187
+ catalog.record(
188
+ MethodSignature.new(
189
+ class_name: class_name,
190
+ method_name: method_name,
191
+ kind: kind,
192
+ params: parsed.params,
193
+ return_type: parsed.return_type,
194
+ modifiers: parsed.modifiers
195
+ )
196
+ )
197
+ end
198
+
199
+ def singleton_method?(def_node, in_singleton_class)
200
+ in_singleton_class || def_node.receiver.is_a?(Prism::SelfNode)
201
+ end
202
+
203
+ # Resolves a constant-path node (`Foo::Bar`,
204
+ # `::Foo::Bar`) to its dot-separated name. Returns nil
205
+ # for the rare dynamic-prefix shape so the walker
206
+ # doesn't guess a qualified name in that case.
207
+ def qualified_name_for(node)
208
+ case node
209
+ when Prism::ConstantReadNode then node.name.to_s
210
+ when Prism::ConstantPathNode
211
+ parts = []
212
+ current = node
213
+ while current.is_a?(Prism::ConstantPathNode)
214
+ parts.unshift(current.name.to_s)
215
+ current = current.parent
216
+ end
217
+ case current
218
+ when nil then "::#{parts.join('::')}"
219
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end