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,660 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "rigor/plugin"
5
+
6
+ require_relative "sorbet/method_signature"
7
+ require_relative "sorbet/catalog"
8
+ require_relative "sorbet/type_translator"
9
+ require_relative "sorbet/sig_parser"
10
+ require_relative "sorbet/catalog_walker"
11
+ require_relative "sorbet/assertion_recognizer"
12
+ require_relative "sorbet/absurd_recognizer"
13
+ require_relative "sorbet/sigil_detector"
14
+
15
+ module Rigor
16
+ module Plugin
17
+ # rigor-sorbet — ingests Sorbet `sig { ... }` blocks as
18
+ # method-signature contributions to Rigor's analyzer.
19
+ #
20
+ # ADR-11 slice 1 — first deliverable. Recognises:
21
+ #
22
+ # - `sig { params(x: Integer).returns(String) }` above a
23
+ # `def foo(x)` definition, contributing the parsed return
24
+ # type at every call site.
25
+ # - The `void` terminus and the `abstract` / `override` /
26
+ # `overridable` / `final` modifiers (recorded on the
27
+ # {MethodSignature} for slice ≥2).
28
+ # - `class Foo` / `module Foo::Bar` / `class << self`
29
+ # nesting; `def self.foo` is recognised as a singleton
30
+ # method.
31
+ #
32
+ # Slice 1 vocabulary is the bare minimum to round-trip the
33
+ # most common sig shapes; the {TypeTranslator} table
34
+ # documents what's covered. Anything else (T.proc / T::Array
35
+ # / T.class_of / T::Struct) degrades silently to
36
+ # `Dynamic[top]` for now — slice 3 widens the translator.
37
+ #
38
+ # Architecture: per-run `Catalog` is built lazily on first
39
+ # access by walking every configured `paths:` entry's `.rb`
40
+ # files plus every `rbi_paths:` entry's `.rbi` files (slice
41
+ # 4) via the plugin's `IoBoundary`. The catalog is frozen
42
+ # after the first build and consulted by
43
+ # `#flow_contribution_for` at every call site. RBI files
44
+ # share the catalog with project-source sigs — both produce
45
+ # `MethodSignature` entries keyed by
46
+ # `(class_name, method_name, kind)`. When a key collides
47
+ # across files, the last-walked sig wins (ordering is
48
+ # platform-dependent: `Dir.glob` returns directory entries
49
+ # in filesystem order). Sorbet's full shim-override
50
+ # semantics — `sorbet/rbi/shims/` overriding
51
+ # `sorbet/rbi/gems/` — lands in a later slice once the
52
+ # catalog gains per-source provenance.
53
+ #
54
+ # The plugin emits `plugin.sorbet.parse-error` warnings for
55
+ # malformed sig blocks (no block / empty block / no
56
+ # `returns` or `void` terminus / two consecutive sigs / sig
57
+ # not followed by a def) but never aborts a run.
58
+ #
59
+ # ## Configuration
60
+ #
61
+ # plugins:
62
+ # - gem: rigor-sorbet
63
+ # config:
64
+ # paths: ["lib", "app"] # directories to scan for `.rb` sigs; defaults to `paths:`
65
+ # rbi_paths: ["sorbet/rbi"] # directories to scan for `.rbi` files; default shown
66
+ #
67
+ # The `paths:` config key narrows the plugin's `.rb` walk;
68
+ # omit it to inherit the project-wide `paths:` value. The
69
+ # `rbi_paths:` key controls where Sorbet's RBI tree is read
70
+ # from — defaults to `sorbet/rbi/` per Tapioca's standard
71
+ # layout (`gems/`, `annotations/`, `dsl/`, `shims/`). Set
72
+ # to `[]` to opt out of RBI loading entirely.
73
+ class Sorbet < Rigor::Plugin::Base
74
+ manifest(
75
+ id: "sorbet",
76
+ version: "0.1.0",
77
+ description: "Ingests Sorbet `sig` blocks as method-signature contributions.",
78
+ config_schema: {
79
+ "paths" => :array,
80
+ "rbi_paths" => :array,
81
+ "enforce_sigil" => :boolean
82
+ }
83
+ )
84
+
85
+ # Default RBI directory tree. Matches the layout
86
+ # `tapioca init` generates — see Sorbet's `rbi.md`. Slice 4
87
+ # walks every `.rbi` file under these roots recursively;
88
+ # the four standard Tapioca subdirectories
89
+ # (`gems` / `annotations` / `dsl` / `shims`) are picked
90
+ # up as a side effect of recursing into the parent root.
91
+ DEFAULT_RBI_PATHS = ["sorbet/rbi"].freeze
92
+
93
+ def init(services)
94
+ @services = services
95
+ @configured_paths = Array(config.fetch("paths", services.configuration.paths)).map(&:to_s)
96
+ @rbi_paths = Array(config.fetch("rbi_paths", DEFAULT_RBI_PATHS)).map(&:to_s)
97
+ # Default `true` — only files marked `# typed: true` /
98
+ # `:strict` / `:strong` contribute their sigs. Set to
99
+ # `false` to record every file's sigs regardless of
100
+ # sigil (current behaviour pre-this-config).
101
+ @enforce_sigil = config.fetch("enforce_sigil", true)
102
+ # ADR-11 deferred follow-up — per-call-site assertion
103
+ # gating. Catalog harvest's `@sigil_by_path` cache is
104
+ # consulted at every `flow_contribution_for` call so
105
+ # `T.let` / `T.cast` / `T.must` / `T.bind` /
106
+ # `T.assert_type!` only fire in files Sorbet itself
107
+ # would enforce (`# typed: true` / `:strict` /
108
+ # `:strong`). When `@enforce_sigil` is off (the user
109
+ # opted out at harvest time), the gate also opens at
110
+ # every call site — current behaviour. Files whose
111
+ # sigil hasn't been observed yet (e.g. the catalog
112
+ # hasn't run, or the call site is in a fixture /
113
+ # synthetic path the harvest didn't see) treat
114
+ # missing-info as enforced — failing-open is friendlier
115
+ # for spec ergonomics than failing-closed.
116
+ @sigil_by_path = {}
117
+ @catalog = nil
118
+ @parse_errors_by_path = {}
119
+ @catalog_built = false
120
+ # ADR-11 slice 6 — Prism nodes for `T.absurd` calls
121
+ # we observed in `flow_contribution_for` to be
122
+ # *reachable* (i.e., their discriminant didn't narrow
123
+ # to `bot`). `diagnostics_for_file` walks the per-file
124
+ # AST and surfaces these as `plugin.sorbet.absurd-reachable`
125
+ # warnings. Hash is keyed on the Prism node's
126
+ # `object_id` because the runner only parses each file
127
+ # once per run, so identity is stable across the two
128
+ # plugin hooks.
129
+ @reachable_absurd_nodes = {}.compare_by_identity
130
+ # ADR-11 light follow-up — `T.reveal_type` calls
131
+ # observed in `flow_contribution_for`, paired with the
132
+ # display string for the inferred type at the call site.
133
+ # Mirrors the absurd-node compare-by-identity hash;
134
+ # `diagnostics_for_file` surfaces each entry as a
135
+ # `plugin.sorbet.reveal-type` `:info` diagnostic.
136
+ @reveal_type_calls = {}.compare_by_identity
137
+ # T.bind / T.assert_type! priority slice 1 —
138
+ # `T.assert_type!` calls observed in
139
+ # `flow_contribution_for` whose static subtype check
140
+ # FAILED, paired with the inferred + asserted type
141
+ # display strings. Same compare-by-identity discipline.
142
+ # `diagnostics_for_file` walks the file AST for
143
+ # `T.assert_type!` calls and surfaces matching entries
144
+ # as `plugin.sorbet.assert-type-mismatch` `:error`
145
+ # diagnostics.
146
+ @assert_type_mismatches = {}.compare_by_identity
147
+ end
148
+
149
+ def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
150
+ ensure_catalog
151
+ # The catalog records errors under the canonicalised
152
+ # (realpath-resolved) form; the runner may pass the
153
+ # symlink-bearing form here. Look up under both so the
154
+ # match is symlink-agnostic.
155
+ errors = @parse_errors_by_path[path] || @parse_errors_by_path[canonicalize(path)] || []
156
+ diagnostics = errors.map { |error| parse_error_diagnostic(path, error) }
157
+ diagnostics.concat(absurd_reachable_diagnostics(path, root))
158
+ diagnostics.concat(reveal_type_diagnostics(path, root))
159
+ diagnostics.concat(assert_type_mismatch_diagnostics(path, root))
160
+ diagnostics
161
+ end
162
+
163
+ # ADR-11 slice 1 — return-type contribution from the
164
+ # parsed `sig { ... }` block. Resolves the receiver in two
165
+ # passes:
166
+ #
167
+ # 1. Constant receiver (`User.find(...)`) → singleton-side
168
+ # catalog lookup.
169
+ # 2. Nominal receiver-type (`user.name` where `user`'s
170
+ # inferred type is `Nominal["User"]`) → instance-side
171
+ # catalog lookup.
172
+ #
173
+ # Implicit-self calls (no receiver, current-class method)
174
+ # are deferred to slice 2 — slice 1 covers the common case
175
+ # where the sig is on the called method's own class.
176
+ def flow_contribution_for(call_node:, scope:)
177
+ return nil unless call_node.is_a?(Prism::CallNode)
178
+
179
+ # ADR-11 slice 6 — `T.absurd(x)` exhaustiveness. Always
180
+ # contributes a `bot` return + raise effect (matches
181
+ # Sorbet's runtime behaviour); when the discriminant
182
+ # *isn't* narrowed to `bot` at this scope, also records
183
+ # the call node so `diagnostics_for_file` can surface a
184
+ # `plugin.sorbet.absurd-reachable` warning.
185
+ if AbsurdRecognizer.absurd_call?(call_node)
186
+ @reachable_absurd_nodes[call_node] = true unless AbsurdRecognizer.exhaustive?(call_node, scope)
187
+ return AbsurdRecognizer.contribution(call_node, manifest.id)
188
+ end
189
+
190
+ # ADR-11 slice 2 — `T.let` / `T.cast` / `T.must` /
191
+ # `T.unsafe` are checked first because they're cheaper
192
+ # to recognise (no catalog walk required) and they
193
+ # win over any cataloged signature: the user explicitly
194
+ # asserted the type at the call site. The light
195
+ # follow-up extends the recogniser to `T.must_because`
196
+ # (alias of `T.must`) and `T.reveal_type` (passes the
197
+ # type through; the human-facing diagnostic is recorded
198
+ # here for `diagnostics_for_file` to emit).
199
+ #
200
+ # Per-call-site sigil gating: with `enforce_sigil: true`
201
+ # (default), assertions only fire in files Sorbet itself
202
+ # would enforce. Files at `# typed: false` (or
203
+ # sigil-less, which Sorbet treats as `:false`) skip the
204
+ # assertion path entirely so the dispatcher continues
205
+ # through the next tier as if the wrapper weren't
206
+ # there. The catalog tier already gates by sigil at
207
+ # harvest time; this closes the matching gap for
208
+ # caller-side recognition.
209
+ ensure_catalog
210
+ if assertion_enforced_here?(scope)
211
+ assertion = AssertionRecognizer.recognize(
212
+ call_node: call_node, scope: scope, plugin_id: manifest.id
213
+ )
214
+ if assertion
215
+ record_reveal_type_call(call_node, assertion.return_type) if call_node.name == :reveal_type
216
+ record_assert_type_check(call_node, scope) if call_node.name == :assert_type!
217
+ return assertion
218
+ end
219
+ end
220
+
221
+ return nil if @catalog.nil? || @catalog.empty?
222
+
223
+ signature = lookup_signature(call_node, scope)
224
+ return nil if signature.nil?
225
+
226
+ return_type = signature.return_type
227
+ return nil if return_type.nil?
228
+
229
+ Rigor::FlowContribution.new(
230
+ return_type: return_type,
231
+ provenance: Rigor::FlowContribution::Provenance.new(
232
+ source_family: "plugin.#{manifest.id}",
233
+ plugin_id: manifest.id,
234
+ node: call_node,
235
+ descriptor: nil
236
+ )
237
+ )
238
+ end
239
+
240
+ private
241
+
242
+ # ADR-11 deferred follow-up — per-call-site assertion
243
+ # gating. With `enforce_sigil: false`, the gate is fully
244
+ # open (matches the pre-feature behaviour). With
245
+ # `enforce_sigil: true` (default), the caller file's
246
+ # sigil must reach `:true` / `:strict` / `:strong` for
247
+ # assertions to fire. Three honest fallbacks:
248
+ #
249
+ # - `scope.source_path` is nil — synthetic call sites
250
+ # (specs, virtual-node fixtures) have no file context.
251
+ # Default to enforced so existing recogniser tests
252
+ # keep working.
253
+ # - the path is canonicalised to a form not in
254
+ # `@sigil_by_path` — the harvest never saw this file
255
+ # (out-of-tree call site, or a path the
256
+ # `configured_paths` config excluded). Sorbet itself
257
+ # has no opinion on such files; default to enforced
258
+ # so the recogniser still fires.
259
+ # - the path IS in `@sigil_by_path` but at `:false` /
260
+ # `:ignore` — gate closes.
261
+ def assertion_enforced_here?(scope)
262
+ return true unless @enforce_sigil
263
+
264
+ path = scope&.source_path
265
+ return true if path.nil?
266
+ return true unless @catalog_built
267
+
268
+ level = @sigil_by_path[path] || @sigil_by_path[canonicalize(path)]
269
+ return true if level.nil?
270
+
271
+ SigilDetector.enforced?(level)
272
+ end
273
+
274
+ def lookup_signature(call_node, scope)
275
+ receiver = call_node.receiver
276
+ method_name = call_node.name
277
+ return nil if method_name.nil?
278
+
279
+ if (singleton_target = constant_receiver_name(receiver))
280
+ # `Post.find(...)` — direct singleton method, or
281
+ # `extend M` lifting `M#find` to the extending class.
282
+ chain_lookup(singleton_target, method_name, anchor_kind: :singleton, mixin_kind: :extend)
283
+ elsif receiver
284
+ instance_chain_lookup(receiver, method_name, scope)
285
+ end
286
+ end
287
+
288
+ def instance_chain_lookup(receiver_node, method_name, scope)
289
+ return nil if scope.nil?
290
+
291
+ receiver_type = scope.type_of(receiver_node)
292
+ return nil unless receiver_type.is_a?(Rigor::Type::Nominal)
293
+
294
+ chain_lookup(receiver_type.class_name, method_name, anchor_kind: :instance, mixin_kind: :include)
295
+ rescue StandardError
296
+ # `scope.type_of` can raise on unrecognised synthetic
297
+ # nodes; degrade to "no contribution" rather than
298
+ # bubbling the failure into the dispatcher.
299
+ nil
300
+ end
301
+
302
+ # ADR-11 slice 8 — chain-aware catalog lookup.
303
+ #
304
+ # For instance-side calls (`post.body`):
305
+ # - `anchor_kind: :instance` (try `Post#body` first)
306
+ # - `mixin_kind: :include` (then walk Post's `include`d
307
+ # modules and try `Foo#body` on each)
308
+ #
309
+ # For singleton-side calls (`Post.find`):
310
+ # - `anchor_kind: :singleton` (try `Post.find` first)
311
+ # - `mixin_kind: :extend` (then walk Post's `extend`ed
312
+ # modules and try `Foo#find` *as :instance* — `extend
313
+ # Foo` lifts Foo's INSTANCE methods to the extending
314
+ # class's SINGLETON methods, matching Ruby's MRO).
315
+ def chain_lookup(class_name, method_name, anchor_kind:, mixin_kind:)
316
+ each_class_form(class_name).each do |form|
317
+ sig = @catalog.lookup(class_name: form, method_name: method_name, kind: anchor_kind)
318
+ return sig if sig
319
+ end
320
+
321
+ visited = Set.new
322
+ queue = mixin_modules_for(class_name, mixin_kind).dup
323
+
324
+ until queue.empty?
325
+ candidate = queue.shift
326
+ next unless visited.add?(candidate)
327
+
328
+ forms_for_mixin(class_name, candidate).each do |form|
329
+ sig = @catalog.lookup(class_name: form, method_name: method_name, kind: :instance)
330
+ return sig if sig
331
+
332
+ # Transitive: an `include` inside the mixed-in
333
+ # module is also inherited by the host class.
334
+ mixin_modules_for(form, :include).each do |inner|
335
+ queue << inner unless visited.include?(inner)
336
+ end
337
+ end
338
+ end
339
+
340
+ nil
341
+ end
342
+
343
+ # `Post` and `::Post` are routinely confused at the catalog
344
+ # boundary (the walker records the lexical name; user code
345
+ # often writes the rooted form). Try both at every lookup.
346
+ def each_class_form(class_name)
347
+ [class_name, "::#{class_name}"]
348
+ end
349
+
350
+ # Resolution forms for a mixed-in module name. Tapioca's
351
+ # generated DSL RBIs use the nested form
352
+ # (`class Post; module GeneratedAttributeMethods; ...; end`);
353
+ # hand-written shims often use the top-level form
354
+ # (`module GeneratedAttributeMethods; ...; end` outside any
355
+ # class); explicit rooting (`::GeneratedAttributeMethods`)
356
+ # is occasionally seen. Try all three.
357
+ def forms_for_mixin(host_class, mixin_name)
358
+ if mixin_name.start_with?("::")
359
+ [mixin_name, mixin_name.delete_prefix("::")]
360
+ else
361
+ ["#{host_class}::#{mixin_name}", mixin_name, "::#{mixin_name}"]
362
+ end
363
+ end
364
+
365
+ def mixin_modules_for(class_name, kind)
366
+ each_class_form(class_name).flat_map { |form| @catalog.mixins_for(form)[kind] }.uniq
367
+ end
368
+
369
+ def constant_receiver_name(node)
370
+ case node
371
+ when Prism::ConstantReadNode then node.name.to_s
372
+ when Prism::ConstantPathNode then constant_path_name(node)
373
+ end
374
+ end
375
+
376
+ def constant_path_name(node)
377
+ parts = []
378
+ current = node
379
+ while current.is_a?(Prism::ConstantPathNode)
380
+ parts.unshift(current.name.to_s)
381
+ current = current.parent
382
+ end
383
+ case current
384
+ when nil then "::#{parts.join('::')}"
385
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
386
+ end
387
+ end
388
+
389
+ def ensure_catalog
390
+ return @catalog if @catalog_built
391
+
392
+ catalog = Catalog.new
393
+ # Project source — `.rb` only.
394
+ @configured_paths.each { |root| harvest_path(root, catalog, extensions: %w[.rb]) }
395
+ # Sorbet RBI tree — `.rbi` only. Slice 4 of ADR-11.
396
+ @rbi_paths.each { |root| harvest_path(root, catalog, extensions: %w[.rbi]) }
397
+ catalog.freeze!
398
+ @catalog = catalog
399
+ @catalog_built = true
400
+ catalog
401
+ end
402
+
403
+ # @param root [String] directory or single file.
404
+ # @param catalog [Catalog]
405
+ # @param extensions [Array<String>] file extensions to
406
+ # accept (e.g. `[".rb"]` for project source,
407
+ # `[".rbi"]` for Sorbet RBI tree).
408
+ def harvest_path(root, catalog, extensions:)
409
+ absolute = canonicalize(root)
410
+ if File.directory?(absolute)
411
+ extensions.each do |ext|
412
+ Dir.glob(File.join(absolute, "**", "*#{ext}")).each do |path|
413
+ harvest_file(canonicalize(path), catalog)
414
+ end
415
+ end
416
+ elsif File.file?(absolute) && extensions.any? { |ext| absolute.end_with?(ext) }
417
+ # `paths:` may list individual files (the demos do
418
+ # this); walk them directly rather than skipping.
419
+ harvest_file(absolute, catalog)
420
+ end
421
+ end
422
+
423
+ # Canonicalises a path through `File.realpath` so it
424
+ # matches the form `Plugin::TrustPolicy#allow_read?` sees
425
+ # (the runner builds the policy's roots from `Dir.pwd`,
426
+ # which has symlinks resolved on macOS — `/tmp` →
427
+ # `/private/tmp` etc.). Falls back to `File.expand_path`
428
+ # when realpath fails (e.g. the path no longer exists).
429
+ def canonicalize(path)
430
+ expanded = File.expand_path(path)
431
+ File.exist?(expanded) ? File.realpath(expanded) : expanded
432
+ rescue StandardError
433
+ expanded
434
+ end
435
+
436
+ def harvest_file(path, catalog)
437
+ contents = io_boundary.read_file(path)
438
+ return if contents.nil?
439
+
440
+ # ADR-11 slice 5 — honour Sorbet's `# typed: ignore`
441
+ # magic comment by skipping the file entirely.
442
+ level = SigilDetector.detect(contents)
443
+ # Per-call-site assertion gating consults this map at
444
+ # `flow_contribution_for`. Recorded BEFORE the ignored
445
+ # short-circuit so a `# typed: ignore` file still
446
+ # reports its level to the gate (the gate then chooses
447
+ # to suppress assertions there too — `ignore` is
448
+ # stricter than `false`).
449
+ @sigil_by_path[path] = level
450
+ return if SigilDetector.ignored?(level)
451
+
452
+ result = Prism.parse(contents)
453
+ return unless result.errors.empty?
454
+
455
+ # `enforce_sigil` follow-up — when on (default), files
456
+ # at `:false` (or sigil-less, which Sorbet treats as
457
+ # `:false`) are STILL walked so parse-error diagnostics
458
+ # surface, but sigs flow into a discardable catalog
459
+ # rather than the per-run one. Sorbet itself doesn't
460
+ # enforce types at `# typed: false`, and Rigor mirrors
461
+ # that for sig contributions. Assertion recognisers
462
+ # (`T.let` / `T.cast` / `T.must` / `T.bind` /
463
+ # `T.assert_type!`) stay live regardless of sigil — the
464
+ # user wrote those deliberately.
465
+ sig_catalog = if @enforce_sigil && !SigilDetector.enforced?(level)
466
+ Catalog.new
467
+ else
468
+ catalog
469
+ end
470
+
471
+ errors = CatalogWalker.walk(root: result.value, catalog: sig_catalog, path: path)
472
+ @parse_errors_by_path[path] = errors unless errors.empty?
473
+ rescue Plugin::AccessDeniedError, Errno::ENOENT
474
+ # Skip files outside the trusted read scope or that
475
+ # vanished between glob and read; the plugin produces
476
+ # no output for them.
477
+ nil
478
+ end
479
+
480
+ # Walks the per-file AST looking for `T.absurd(x)` call
481
+ # nodes and emits a `plugin.sorbet.absurd-reachable`
482
+ # warning for any whose object identity matches
483
+ # `@reachable_absurd_nodes` (populated during the engine's
484
+ # earlier pass through `flow_contribution_for`). Pops
485
+ # matched entries so a duplicate run doesn't double-emit.
486
+ def absurd_reachable_diagnostics(path, root)
487
+ return [] if @reachable_absurd_nodes.empty?
488
+
489
+ diagnostics = []
490
+ walk_for_absurd(root) do |call_node|
491
+ next unless @reachable_absurd_nodes.delete(call_node)
492
+
493
+ diagnostics << absurd_diagnostic(path, call_node)
494
+ end
495
+ diagnostics
496
+ end
497
+
498
+ def walk_for_absurd(node, &)
499
+ return unless node.is_a?(Prism::Node)
500
+
501
+ yield node if node.is_a?(Prism::CallNode) && AbsurdRecognizer.absurd_call?(node)
502
+ node.compact_child_nodes.each { |child| walk_for_absurd(child, &) }
503
+ end
504
+
505
+ def absurd_diagnostic(path, call_node)
506
+ location = call_node.location
507
+ Rigor::Analysis::Diagnostic.new(
508
+ path: path,
509
+ line: location.start_line,
510
+ column: location.start_column + 1,
511
+ message: "`T.absurd` is reachable: the discriminant did not narrow to `T.noreturn`. " \
512
+ "Either add the missing case branch above the `else`, or remove the " \
513
+ "`T.absurd(...)` call.",
514
+ severity: :warning,
515
+ rule: "absurd-reachable"
516
+ )
517
+ end
518
+
519
+ # ADR-11 light follow-up — `T.reveal_type(expr)` records
520
+ # the inferred type at recogniser time so the per-file
521
+ # diagnostic hook can surface the human-facing message.
522
+ # The reveal call's contribution already preserved the
523
+ # inferred type for downstream chaining; this hash carries
524
+ # the *display* string that the diagnostic shows.
525
+ def record_reveal_type_call(call_node, return_type)
526
+ @reveal_type_calls[call_node] = display_for_type(return_type)
527
+ end
528
+
529
+ def display_for_type(type)
530
+ # `Type#describe` is the human-facing display contract
531
+ # used by `rigor type-of`'s text renderer.
532
+ return "untyped" if type.nil?
533
+
534
+ type.respond_to?(:describe) ? type.describe : type.inspect
535
+ end
536
+
537
+ def reveal_type_diagnostics(path, root)
538
+ return [] if @reveal_type_calls.empty?
539
+
540
+ diagnostics = []
541
+ walk_for_reveal_type(root) do |call_node|
542
+ display = @reveal_type_calls.delete(call_node)
543
+ next if display.nil?
544
+
545
+ diagnostics << reveal_type_diagnostic(path, call_node, display)
546
+ end
547
+ diagnostics
548
+ end
549
+
550
+ def walk_for_reveal_type(node, &)
551
+ return unless node.is_a?(Prism::Node)
552
+
553
+ if node.is_a?(Prism::CallNode) && node.name == :reveal_type &&
554
+ TypeTranslator.sorbet_t_namespaced?(node.receiver)
555
+ yield node
556
+ end
557
+ node.compact_child_nodes.each { |child| walk_for_reveal_type(child, &) }
558
+ end
559
+
560
+ def reveal_type_diagnostic(path, call_node, display)
561
+ location = call_node.location
562
+ Rigor::Analysis::Diagnostic.new(
563
+ path: path,
564
+ line: location.start_line,
565
+ column: location.start_column + 1,
566
+ message: "`T.reveal_type` inferred type: #{display}",
567
+ severity: :info,
568
+ rule: "reveal-type"
569
+ )
570
+ end
571
+
572
+ # T.bind / T.assert_type! priority slice 1 — runs the
573
+ # static subtype check at recogniser time and records the
574
+ # call only when the inferred type is *provably
575
+ # incompatible* with the asserted type. Gradual
576
+ # consistency rules (`Inference::Acceptance.accepts(...)`
577
+ # mode `:gradual`): a `Dynamic[top]` inferred type
578
+ # silences the check; a definite `:no` records for
579
+ # diagnostic emission; `:maybe` (uncertain) is treated as
580
+ # "trust the user" and silenced — the runtime check is
581
+ # there for those cases.
582
+ def record_assert_type_check(call_node, scope)
583
+ check = AssertionRecognizer.assert_type_check(call_node, scope)
584
+ return if check.nil?
585
+
586
+ inferred, asserted = check
587
+ return if inferred.nil?
588
+
589
+ result = Rigor::Inference::Acceptance.accepts(asserted, inferred)
590
+ return unless result.no?
591
+
592
+ @assert_type_mismatches[call_node] = [display_for_type(inferred), display_for_type(asserted)]
593
+ end
594
+
595
+ def assert_type_mismatch_diagnostics(path, root)
596
+ return [] if @assert_type_mismatches.empty?
597
+
598
+ diagnostics = []
599
+ walk_for_assert_type(root) do |call_node|
600
+ recorded = @assert_type_mismatches.delete(call_node)
601
+ next if recorded.nil?
602
+
603
+ diagnostics << assert_type_mismatch_diagnostic(path, call_node, *recorded)
604
+ end
605
+ diagnostics
606
+ end
607
+
608
+ def walk_for_assert_type(node, &)
609
+ return unless node.is_a?(Prism::Node)
610
+
611
+ if node.is_a?(Prism::CallNode) && node.name == :assert_type! &&
612
+ TypeTranslator.sorbet_t_namespaced?(node.receiver)
613
+ yield node
614
+ end
615
+ node.compact_child_nodes.each { |child| walk_for_assert_type(child, &) }
616
+ end
617
+
618
+ def assert_type_mismatch_diagnostic(path, call_node, inferred_display, asserted_display)
619
+ location = call_node.location
620
+ Rigor::Analysis::Diagnostic.new(
621
+ path: path,
622
+ line: location.start_line,
623
+ column: location.start_column + 1,
624
+ message: "`T.assert_type!` failed: inferred type #{inferred_display} is not " \
625
+ "compatible with asserted type #{asserted_display}.",
626
+ severity: :error,
627
+ rule: "assert-type-mismatch"
628
+ )
629
+ end
630
+
631
+ def parse_error_diagnostic(path, error)
632
+ location = error.node.location
633
+ Rigor::Analysis::Diagnostic.new(
634
+ path: path,
635
+ line: location.start_line,
636
+ column: location.start_column + 1,
637
+ message: parse_error_message(error.kind),
638
+ severity: :warning,
639
+ rule: "parse-error"
640
+ )
641
+ end
642
+
643
+ def parse_error_message(kind)
644
+ case kind
645
+ when :no_block then "Sorbet `sig` call missing a block."
646
+ when :empty_block then "Sorbet `sig` block is empty."
647
+ when :missing_returns_or_void
648
+ "Sorbet `sig` block must end in `.returns(...)` or `.void`."
649
+ when :duplicate_sig
650
+ "Two `sig` blocks in a row; the first one has no following method definition."
651
+ when :dangling_sig
652
+ "`sig` block is not immediately followed by a method definition."
653
+ else "Sorbet `sig` block did not parse (#{kind})."
654
+ end
655
+ end
656
+ end
657
+
658
+ Rigor::Plugin.register(Sorbet)
659
+ end
660
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rigor/plugin/sorbet"