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,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class Sorbet < Rigor::Plugin::Base
6
+ # Frozen description of one Sorbet `sig` block as parsed by
7
+ # {SigParser}. Holds enough to reconstruct the method's
8
+ # call-site return type (slice 1's deliverable) plus the
9
+ # parameter shape and modifier list (kept for slice 2+ when
10
+ # we begin checking call-site argument types and override
11
+ # compatibility).
12
+ #
13
+ # `kind` distinguishes `def foo` (`:instance`) from
14
+ # `def self.foo` / `class << self; def foo; end`
15
+ # (`:singleton`).
16
+ #
17
+ # `modifiers` is the set of `sig`-level modifiers we
18
+ # observed: `:abstract`, `:override`, `:overridable`,
19
+ # `:final`. Slice 1 records them but does not act on them;
20
+ # later slices wire `:abstract` into the existing
21
+ # `def.return-type-mismatch` check and `:override` into
22
+ # override-compatibility validation.
23
+ MethodSignature = Data.define(
24
+ :class_name, :method_name, :kind, :params, :return_type, :modifiers
25
+ )
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,154 @@
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
+ # Mini-interpreter for the chained-call expression that
11
+ # makes up a Sorbet `sig` block. The block body is always
12
+ # a single expression — Sorbet's docs (`sigs.md`) show the
13
+ # full grammar:
14
+ #
15
+ # sig { params(x: T, y: T).returns(U) }
16
+ # sig { void }
17
+ # sig { abstract.params(...).returns(...) }
18
+ # sig { override.params(...).void }
19
+ # sig { type_parameters(:U).params(...).returns(...) }
20
+ # sig do
21
+ # params(...)
22
+ # .returns(...)
23
+ # end
24
+ #
25
+ # The parser walks the chain right-to-left, gathering
26
+ # whatever it recognises (`params` / `returns` / `void` /
27
+ # `abstract` / `override` / `overridable` / `final` /
28
+ # `type_parameters` / `checked` / `on_failure`) into a
29
+ # frozen result hash. Slice 1 wires the parsed structure
30
+ # into {MethodSignature}; later slices will start *acting*
31
+ # on the modifiers and `type_parameters`.
32
+ #
33
+ # The parser is intentionally tolerant — unknown chain
34
+ # nodes degrade to "the rest of the chain is opaque" rather
35
+ # than raising. The plugin emits a diagnostic
36
+ # (`plugin.sorbet.parse-error`) only when the entire chain
37
+ # fails to yield either a `returns` or a `void`.
38
+ module SigParser
39
+ # Modifiers we recognise at any position in the chain.
40
+ # Stored in `:modifiers` on the parse result.
41
+ RECOGNISED_MODIFIERS = %i[abstract override overridable final].freeze
42
+
43
+ # Sorbet runtime-only chain steps. Recognised so the
44
+ # parser doesn't degrade the whole sig when it sees them,
45
+ # but their payload is intentionally discarded.
46
+ RUNTIME_ONLY_STEPS = %i[checked on_failure].freeze
47
+
48
+ ParseResult = Data.define(:return_type, :params, :modifiers, :void) do
49
+ def void? = void
50
+ end
51
+
52
+ ParseError = Data.define(:reason, :node)
53
+
54
+ module_function
55
+
56
+ # @param sig_call [Prism::CallNode] the `sig { ... }` /
57
+ # `sig do ... end` call.
58
+ # @return [ParseResult, ParseError]
59
+ def parse(sig_call)
60
+ return ParseError.new(reason: :no_block, node: sig_call) if sig_call.block.nil?
61
+
62
+ body = sig_call.block.body
63
+ chain_root = first_statement(body)
64
+ return ParseError.new(reason: :empty_block, node: sig_call) if chain_root.nil?
65
+
66
+ fold_chain(chain_root, sig_call)
67
+ end
68
+
69
+ def first_statement(body)
70
+ case body
71
+ when Prism::StatementsNode then body.body.first
72
+ else body
73
+ end
74
+ end
75
+
76
+ # Walks the chain bottom-up. Each chain link is a
77
+ # `Prism::CallNode` whose receiver is the next link;
78
+ # `params` / `returns` / `void` may appear at any
79
+ # position, so we accumulate their effect into a
80
+ # mutable hash and freeze on the way out.
81
+ def fold_chain(node, sig_call)
82
+ accumulator = { return_type: nil, params: {}, modifiers: [], void: false, terminus_kind: nil }
83
+ current = node
84
+
85
+ while current.is_a?(Prism::CallNode)
86
+ case current.name
87
+ when :returns
88
+ accumulator[:return_type] = TypeTranslator.translate(first_argument(current))
89
+ accumulator[:terminus_kind] ||= :returns
90
+ when :void
91
+ accumulator[:void] = true
92
+ accumulator[:terminus_kind] ||= :void
93
+ when :params
94
+ accumulator[:params].merge!(parse_params(current))
95
+ when :type_parameters
96
+ # Slice 1: recognise to suppress the degraded
97
+ # path; widen translation in slice 3.
98
+ when *RECOGNISED_MODIFIERS
99
+ accumulator[:modifiers] << current.name
100
+ when *RUNTIME_ONLY_STEPS
101
+ # Discard payload; runtime-only.
102
+ else
103
+ # Unknown chain link — stop folding and treat
104
+ # whatever we accumulated so far as the result.
105
+ break
106
+ end
107
+ current = current.receiver
108
+ end
109
+
110
+ return ParseError.new(reason: :missing_returns_or_void, node: sig_call) if accumulator[:terminus_kind].nil?
111
+
112
+ ParseResult.new(
113
+ return_type: resolve_return_type(accumulator),
114
+ params: accumulator[:params].freeze,
115
+ modifiers: accumulator[:modifiers].uniq.freeze,
116
+ void: accumulator[:void]
117
+ )
118
+ end
119
+
120
+ # `void` and `returns(T)` share the slot; if both are
121
+ # present (unusual but parseable), `returns(T)` wins
122
+ # because Sorbet's static side treats `void` as
123
+ # "discard the value" — when the user explicitly named
124
+ # `T`, that's the more informative shape.
125
+ def resolve_return_type(accumulator)
126
+ accumulator[:return_type] || Rigor::Type::Combinator.untyped
127
+ end
128
+
129
+ # `params(x: Integer, y: T.nilable(String))` — extracts
130
+ # the `KeywordHashNode` AST and translates each value.
131
+ # The result is `{ Symbol => Rigor::Type }`. Splat /
132
+ # double-splat / unrecognised keys degrade silently
133
+ # (slice 1 behaviour).
134
+ def parse_params(call_node)
135
+ args = call_node.arguments&.arguments || []
136
+ first = args.first
137
+ return {} unless first.is_a?(Prism::KeywordHashNode)
138
+
139
+ first.elements.each_with_object({}) do |element, into|
140
+ next unless element.is_a?(Prism::AssocNode)
141
+ next unless element.key.is_a?(Prism::SymbolNode)
142
+
143
+ key = element.key.unescaped.to_sym
144
+ into[key] = TypeTranslator.translate(element.value)
145
+ end
146
+ end
147
+
148
+ def first_argument(call_node)
149
+ call_node.arguments&.arguments&.first
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ class Sorbet < Rigor::Plugin::Base
6
+ # Reads Sorbet's `# typed: <level>` magic comment from the
7
+ # head of a file. Sorbet's own contract (per
8
+ # [`static.md`](https://sorbet.org/docs/static)) requires
9
+ # the sigil to appear at the top of the file before any
10
+ # Ruby code. We're slightly more lenient here — the sigil
11
+ # may appear after a few comment / blank lines (matching
12
+ # what Sorbet itself accepts in practice) but we stop
13
+ # scanning once we hit a non-comment, non-blank line.
14
+ #
15
+ # Recognised levels: `:ignore` / `:false` / `:true` /
16
+ # `:strict` / `:strong`. Falls back to `:false` (Sorbet's
17
+ # default) when no sigil is present, matching how Sorbet
18
+ # treats sigil-less files.
19
+ #
20
+ # Slice 5 of ADR-11 uses this purely at catalog-harvest
21
+ # time: `# typed: ignore` files are skipped entirely (the
22
+ # plugin records no sigs from them). The other levels are
23
+ # detected for forward compatibility but treated
24
+ # identically — per-call-site sigil honouring (e.g. only
25
+ # firing `T.let` recognition in `# typed: true`+ files)
26
+ # requires threading the file path through
27
+ # `flow_contribution_for`, which lives behind a future
28
+ # plugin-contract widening slice.
29
+ module SigilDetector
30
+ # Sorbet's strictness-level names. Stored as symbols to
31
+ # match the analyzer's existing convention for level
32
+ # identifiers; the `:true` / `:false` symbols here are
33
+ # level *names* (the textual sigil values) and are
34
+ # intentionally distinct from the `true` / `false`
35
+ # boolean literals.
36
+ VALID_LEVELS = %i[ignore false true strict strong].freeze
37
+ DEFAULT_LEVEL = :false # rubocop:disable Lint/BooleanSymbol
38
+ SIGIL_REGEX = /\A\s*#\s*typed\s*:\s*(ignore|false|true|strict|strong)\s*\z/
39
+
40
+ # Cap on how many lines we scan before giving up. Sorbet
41
+ # doesn't formally specify a cap, but the sigil
42
+ # convention is "near the top of the file"; 10 lines is
43
+ # generous and bounds the parse cost on enormous files.
44
+ MAX_HEAD_LINES = 10
45
+
46
+ module_function
47
+
48
+ # @param contents [String] raw file contents.
49
+ # @return [Symbol] one of {VALID_LEVELS}; defaults to
50
+ # {DEFAULT_LEVEL} for sigil-less or malformed-sigil
51
+ # files.
52
+ def detect(contents)
53
+ return DEFAULT_LEVEL if contents.nil? || contents.empty?
54
+
55
+ contents.each_line.with_index do |line, index|
56
+ break if index >= MAX_HEAD_LINES
57
+
58
+ stripped = line.strip
59
+ next if stripped.empty?
60
+
61
+ match = SIGIL_REGEX.match(stripped)
62
+ return match[1].to_sym if match
63
+ # First non-blank line that isn't a sigil-shaped
64
+ # comment ends the scan: Sorbet's parser stops at
65
+ # the first directive-or-code line.
66
+ break unless stripped.start_with?("#")
67
+ end
68
+
69
+ DEFAULT_LEVEL
70
+ end
71
+
72
+ # @param level [Symbol]
73
+ # @return [Boolean] true when `# typed: ignore`. The
74
+ # harvest pipeline calls this to short-circuit
75
+ # walking the file's AST.
76
+ def ignored?(level)
77
+ level == :ignore
78
+ end
79
+
80
+ # @return [Boolean] true when `level` is at or above the
81
+ # `# typed: true` mark. Used by the
82
+ # `enforce_sigil` config gate (default `true`): with
83
+ # the gate on, only files marked `:true` / `:strict` /
84
+ # `:strong` contribute their sigs to the catalog. The
85
+ # `:false` (and sigil-less) levels still get walked
86
+ # (so RBI files outside the project can be loaded
87
+ # regardless), but their sig-derived narrowing is
88
+ # suppressed — matching how Sorbet itself only
89
+ # enforces type errors at `# typed: true`+. Assertion
90
+ # recognisers (`T.let` / `T.cast` / `T.must` /
91
+ # `T.bind` / `T.assert_type!`) are NOT gated by this:
92
+ # the user wrote them deliberately, so the
93
+ # recogniser still fires regardless of sigil.
94
+ def enforced?(level)
95
+ %i[true strict strong].include?(level)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,323 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Sorbet < Rigor::Plugin::Base
8
+ # Maps Sorbet's type expressions (the AST inside a `sig`
9
+ # block's `params(...)` and `returns(...)` clauses) into
10
+ # Rigor's internal type carriers.
11
+ #
12
+ # Slice 1 covered the minimum vocabulary that lets a
13
+ # typical `sig { params(x: Integer).returns(String) }`
14
+ # round-trip; slice 3 widens it to cover the dense middle
15
+ # of Sorbet's surface — generic class applications
16
+ # (`T::Array[E]`, `T::Hash[K, V]`, etc.), class-object
17
+ # types (`T.class_of(C)`, `T::Class[T]`), tuples, and
18
+ # shapes:
19
+ #
20
+ # | Sorbet form | Rigor carrier |
21
+ # | ------------------------ | ---------------------------------------- |
22
+ # | `Integer` etc. | `Nominal["Integer"]` |
23
+ # | `::Foo::Bar` | `Nominal["Foo::Bar"]` |
24
+ # | `T.untyped` | `Dynamic[top]` |
25
+ # | `T.anything` | `top` |
26
+ # | `T.noreturn` | `bot` |
27
+ # | `T.nilable(X)` | `Union[X, Constant[nil]]` |
28
+ # | `T.any(A, B, ...)` | `Union[A, B, ...]` |
29
+ # | `T.all(A, B, ...)` | `Intersection[A, B, ...]` |
30
+ # | `T::Boolean` | `Union[Constant[true], Constant[false]]` |
31
+ # | `T::Array[E]` | `Nominal["Array", [E]]` |
32
+ # | `T::Hash[K, V]` | `Nominal["Hash", [K, V]]` |
33
+ # | `T::Set[E]` | `Nominal["Set", [E]]` |
34
+ # | `T::Range[E]` | `Nominal["Range", [E]]` |
35
+ # | `T::Enumerable[E]` | `Nominal["Enumerable", [E]]` |
36
+ # | `T::Enumerator[E]` | `Nominal["Enumerator", [E]]` |
37
+ # | `T::Class[T]` | `Singleton[T-class-name]` (lossy) |
38
+ # | `T.class_of(C)` | `Singleton[C]` |
39
+ # | `[A, B]` (tuple in sig) | `Tuple[A, B]` |
40
+ # | `{a: A, b: B}` (shape) | `HashShape{a: A, b: B}` (closed) |
41
+ #
42
+ # Anything else (`T.proc`, `T.attached_class`,
43
+ # `T.self_type`, `T.type_parameter`, `T::Struct` / `T::Enum`
44
+ # subclasses, …) degrades to `Dynamic[top]`. The degraded
45
+ # path stays silent for now per ADR-11's slice plan; a
46
+ # later slice surfaces the gap as a `dynamic.sorbet.unsupported`
47
+ # diagnostic.
48
+ module TypeTranslator
49
+ BOOLEAN_NAME = "Boolean"
50
+
51
+ # `T::*` constants whose `[]` application maps directly
52
+ # onto a Rigor `Nominal` with the matching standard-
53
+ # library class name. Ordering matches the table above
54
+ # for ease of reading.
55
+ T_GENERIC_CLASSES = {
56
+ "Array" => "Array",
57
+ "Hash" => "Hash",
58
+ "Set" => "Set",
59
+ "Range" => "Range",
60
+ "Enumerable" => "Enumerable",
61
+ "Enumerator" => "Enumerator",
62
+ "Enumerator::Lazy" => "Enumerator::Lazy",
63
+ "Enumerator::Chain" => "Enumerator::Chain"
64
+ }.freeze
65
+
66
+ module_function
67
+
68
+ # @param node [Prism::Node, nil]
69
+ # @return [Rigor::Type] never `nil`; unrecognised forms
70
+ # degrade to `Type::Combinator.untyped`.
71
+ def translate(node)
72
+ return Rigor::Type::Combinator.untyped if node.nil?
73
+
74
+ case node
75
+ when Prism::ConstantReadNode then translate_constant_read(node)
76
+ when Prism::ConstantPathNode then translate_constant_path(node)
77
+ when Prism::CallNode then translate_call(node)
78
+ when Prism::ArrayNode then translate_tuple(node)
79
+ when Prism::HashNode then translate_shape(node)
80
+ else degraded
81
+ end
82
+ end
83
+
84
+ # @param node [Prism::ConstantReadNode]
85
+ def translate_constant_read(node)
86
+ name = node.name.to_s
87
+ return Rigor::Type::Combinator.untyped if name.empty?
88
+
89
+ Rigor::Type::Combinator.nominal_of(name)
90
+ end
91
+
92
+ # @param node [Prism::ConstantPathNode]
93
+ def translate_constant_path(node)
94
+ name = constant_path_name(node)
95
+ return degraded if name.nil?
96
+
97
+ # Sorbet's `T::Boolean` is a special alias rather than a
98
+ # nominal class, expressed as the Boolean type alias.
99
+ return boolean_type if name == "T::Boolean"
100
+
101
+ Rigor::Type::Combinator.nominal_of(name)
102
+ end
103
+
104
+ # `Prism::CallNode` covers two distinct surfaces:
105
+ #
106
+ # 1. `T.something(...)` — `untyped` / `anything` /
107
+ # `noreturn` / `nilable` / `any` / `all` / `class_of`.
108
+ # 2. `T::SomeClass[...]` — the `[]` method on a generic
109
+ # `T::*` constant (slice 3 widening). Maps to
110
+ # `Nominal[name, type_args]`.
111
+ def translate_call(node)
112
+ return translate_t_method(node) if sorbet_t_namespaced?(node.receiver)
113
+ return translate_t_subscript(node) if sorbet_subscript?(node)
114
+
115
+ degraded
116
+ end
117
+
118
+ # Handles the `T.foo(...)` family.
119
+ def translate_t_method(node)
120
+ case node.name
121
+ when :untyped then Rigor::Type::Combinator.untyped
122
+ when :anything then Rigor::Type::Combinator.top
123
+ when :noreturn then Rigor::Type::Combinator.bot
124
+ when :nilable then translate_nilable(node)
125
+ when :any then translate_any(node)
126
+ when :all then translate_all(node)
127
+ when :class_of then translate_class_of(node)
128
+ else degraded
129
+ end
130
+ end
131
+
132
+ def translate_nilable(node)
133
+ inner = first_argument(node)
134
+ return degraded if inner.nil?
135
+
136
+ Rigor::Type::Combinator.union(
137
+ translate(inner), Rigor::Type::Combinator.constant_of(nil)
138
+ )
139
+ end
140
+
141
+ def translate_any(node)
142
+ args = call_arguments(node)
143
+ return degraded if args.empty?
144
+
145
+ Rigor::Type::Combinator.union(*args.map { |arg| translate(arg) })
146
+ end
147
+
148
+ def translate_all(node)
149
+ args = call_arguments(node)
150
+ return degraded if args.empty?
151
+
152
+ Rigor::Type::Combinator.intersection(*args.map { |arg| translate(arg) })
153
+ end
154
+
155
+ # `T.class_of(C)` — singleton-class type for a single
156
+ # constant. Sorbet docs note `T.class_of(MyInterface)`
157
+ # rarely means what users expect (it's the singleton
158
+ # class of `MyInterface`, not "any class implementing
159
+ # the interface"); we honour the literal meaning here
160
+ # and translate to `Singleton[C]`.
161
+ def translate_class_of(node)
162
+ target = first_argument(node)
163
+ name = constant_path_name(target)
164
+ return degraded if name.nil?
165
+
166
+ Rigor::Type::Combinator.singleton_of(name)
167
+ end
168
+
169
+ # Handles `T::Array[E]`, `T::Hash[K, V]`, etc. The Prism
170
+ # AST for `T::Array[Integer]` is a `CallNode` whose
171
+ # receiver is the `T::Array` `ConstantPathNode` and
172
+ # whose `name` is `:[]`. `T::Class[T]` lands here too;
173
+ # we collapse it to `Singleton[name]` (a deliberate
174
+ # narrowing — `T::Class` is structurally generic in
175
+ # Sorbet, but Rigor's `Singleton` carries class identity
176
+ # only).
177
+ def translate_t_subscript(node)
178
+ base_name = sorbet_subscript_base(node.receiver)
179
+ args = call_arguments(node).map { |arg| translate(arg) }
180
+ mapped = T_GENERIC_CLASSES[base_name]
181
+
182
+ if mapped
183
+ Rigor::Type::Combinator.nominal_of(mapped, type_args: args)
184
+ elsif base_name == "Class"
185
+ translate_t_class_subscript(args)
186
+ else
187
+ degraded
188
+ end
189
+ end
190
+
191
+ # `T::Class[T]` — Sorbet's "any class object whose
192
+ # instances are at least `T`". Rigor has no exact
193
+ # analogue (Singleton names a specific class); the
194
+ # closest faithful translation is `Singleton[name]`
195
+ # when `T` is a constant, or `Singleton[Object]` for
196
+ # broader applications. Lossy translation; emitted as
197
+ # `dynamic.sorbet.degraded` once slice 3's diagnostic
198
+ # surface lands.
199
+ def translate_t_class_subscript(args)
200
+ inner = args.first
201
+ return Rigor::Type::Combinator.singleton_of("Class") if inner.nil?
202
+
203
+ case inner
204
+ when Rigor::Type::Nominal then Rigor::Type::Combinator.singleton_of(inner.class_name)
205
+ else Rigor::Type::Combinator.singleton_of("Class")
206
+ end
207
+ end
208
+
209
+ # Tuple types in `sig` position appear as bare array
210
+ # literals: `sig { returns([String, Integer]) }`. Each
211
+ # element is itself a type expression we translate
212
+ # recursively.
213
+ def translate_tuple(node)
214
+ elements = node.elements.map { |element| translate(element) }
215
+ Rigor::Type::Combinator.tuple_of(*elements)
216
+ end
217
+
218
+ # Shape types in `sig` position appear as bare hash
219
+ # literals with symbol keys:
220
+ # `sig { returns({a: Integer, b: String}) }`. Each
221
+ # value is a type expression; the resulting `HashShape`
222
+ # is closed (no extra keys allowed).
223
+ def translate_shape(node)
224
+ pairs = []
225
+ node.elements.each do |element|
226
+ next unless element.is_a?(Prism::AssocNode)
227
+ next unless element.key.is_a?(Prism::SymbolNode)
228
+
229
+ pairs << [element.key.unescaped.to_sym, translate(element.value)]
230
+ end
231
+ Rigor::Type::Combinator.hash_shape_of(pairs)
232
+ end
233
+
234
+ # Renders a constant-path node (`Foo::Bar`, `::Foo::Bar`)
235
+ # as a `::`-joined String. Mirrors the helper used by
236
+ # rigor-activerecord's ModelDiscoverer for parity.
237
+ def constant_path_name(node)
238
+ return nil if node.nil?
239
+
240
+ case node
241
+ when Prism::ConstantReadNode then node.name.to_s
242
+ when Prism::ConstantPathNode then constant_path_name_for_path(node)
243
+ end
244
+ end
245
+
246
+ def constant_path_name_for_path(node)
247
+ parts = []
248
+ current = node
249
+ while current.is_a?(Prism::ConstantPathNode)
250
+ parts.unshift(current.name.to_s)
251
+ current = current.parent
252
+ end
253
+ case current
254
+ when nil
255
+ "::#{parts.join('::')}"
256
+ when Prism::ConstantReadNode
257
+ "#{current.name}::#{parts.join('::')}"
258
+ end
259
+ end
260
+
261
+ def sorbet_t_namespaced?(receiver)
262
+ receiver.is_a?(Prism::ConstantReadNode) && receiver.name == :T
263
+ end
264
+
265
+ # `T::Array[Integer]` parses as `CallNode(receiver: T::Array, name: :[])`.
266
+ # The receiver is a `ConstantPathNode` rooted at the
267
+ # `T` constant.
268
+ def sorbet_subscript?(node)
269
+ node.name == :[] && sorbet_t_qualified?(node.receiver)
270
+ end
271
+
272
+ def sorbet_t_qualified?(node)
273
+ return false unless node.is_a?(Prism::ConstantPathNode)
274
+
275
+ # Walk to the root; require that it terminates at a
276
+ # `T` ConstantReadNode (not an absolute `::T`).
277
+ current = node
278
+ current = current.parent while current.is_a?(Prism::ConstantPathNode)
279
+ current.is_a?(Prism::ConstantReadNode) && current.name == :T
280
+ end
281
+
282
+ # Strips the leading `T::` from a `T::Foo::Bar`
283
+ # constant-path node, returning `"Foo::Bar"`. Returns
284
+ # nil for shapes that aren't `T`-rooted.
285
+ def sorbet_subscript_base(node)
286
+ return nil unless sorbet_t_qualified?(node)
287
+
288
+ parts = []
289
+ current = node
290
+ while current.is_a?(Prism::ConstantPathNode)
291
+ parts.unshift(current.name.to_s)
292
+ current = current.parent
293
+ end
294
+ parts.join("::")
295
+ end
296
+
297
+ def first_argument(node)
298
+ node.arguments&.arguments&.first
299
+ end
300
+
301
+ def call_arguments(node)
302
+ node.arguments&.arguments || []
303
+ end
304
+
305
+ def degraded
306
+ Rigor::Type::Combinator.untyped
307
+ end
308
+
309
+ # `T::Boolean` corresponds to the union of the singleton
310
+ # `true` / `false` values, matching how RBS's `bool`
311
+ # would translate. Built from `Constant[true]` /
312
+ # `Constant[false]` so the analyzer's flow-sensitive
313
+ # narrowing recognises the discriminating shape.
314
+ def boolean_type
315
+ Rigor::Type::Combinator.union(
316
+ Rigor::Type::Combinator.constant_of(true),
317
+ Rigor::Type::Combinator.constant_of(false)
318
+ )
319
+ end
320
+ end
321
+ end
322
+ end
323
+ end