rigortype 0.2.1 → 0.2.3
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.
- checksums.yaml +4 -4
- data/README.md +41 -14
- data/docs/handbook/01-getting-started.md +311 -0
- data/docs/handbook/02-everyday-types.md +337 -0
- data/docs/handbook/03-narrowing.md +359 -0
- data/docs/handbook/04-tuples-and-shapes.md +321 -0
- data/docs/handbook/05-methods-and-blocks.md +339 -0
- data/docs/handbook/06-classes.md +305 -0
- data/docs/handbook/07-rbs-and-extended.md +427 -0
- data/docs/handbook/08-understanding-errors.md +373 -0
- data/docs/handbook/09-plugins.md +241 -0
- data/docs/handbook/10-sorbet.md +347 -0
- data/docs/handbook/11-sig-gen.md +312 -0
- data/docs/handbook/12-lightweight-hkt.md +333 -0
- data/docs/handbook/README.md +275 -0
- data/docs/handbook/appendix-elixir.md +370 -0
- data/docs/handbook/appendix-go.md +399 -0
- data/docs/handbook/appendix-java-csharp.md +470 -0
- data/docs/handbook/appendix-liskov.md +580 -0
- data/docs/handbook/appendix-mypy.md +370 -0
- data/docs/handbook/appendix-phpstan.md +338 -0
- data/docs/handbook/appendix-protocols-and-structural-typing.md +292 -0
- data/docs/handbook/appendix-rust.md +446 -0
- data/docs/handbook/appendix-steep.md +336 -0
- data/docs/handbook/appendix-type-theory.md +1662 -0
- data/docs/handbook/appendix-typeprof.md +416 -0
- data/docs/handbook/appendix-typescript.md +332 -0
- data/docs/install.md +189 -0
- data/docs/llms.txt +72 -0
- data/docs/manual/01-installation.md +342 -0
- data/docs/manual/02-cli-reference.md +569 -0
- data/docs/manual/03-configuration.md +152 -0
- data/docs/manual/04-diagnostics.md +206 -0
- data/docs/manual/05-inspecting-types.md +109 -0
- data/docs/manual/06-baseline.md +104 -0
- data/docs/manual/07-plugins.md +92 -0
- data/docs/manual/08-skills.md +143 -0
- data/docs/manual/09-editor-integration.md +245 -0
- data/docs/manual/10-mcp-server.md +539 -0
- data/docs/manual/11-ci.md +274 -0
- data/docs/manual/12-caching.md +116 -0
- data/docs/manual/13-troubleshooting.md +120 -0
- data/docs/manual/14-rails-quickstart.md +332 -0
- data/docs/manual/15-type-protection-coverage.md +204 -0
- data/docs/manual/16-rbs-extended-annotations.md +190 -0
- data/docs/manual/17-driving-improvement.md +160 -0
- data/docs/manual/README.md +87 -0
- data/docs/manual/ci-templates/README.md +58 -0
- data/docs/manual/plugins/README.md +86 -0
- data/docs/manual/plugins/rigor-actioncable.md +78 -0
- data/docs/manual/plugins/rigor-actionmailer.md +74 -0
- data/docs/manual/plugins/rigor-actionpack.md +80 -0
- data/docs/manual/plugins/rigor-activejob.md +58 -0
- data/docs/manual/plugins/rigor-activerecord.md +102 -0
- data/docs/manual/plugins/rigor-activestorage.md +74 -0
- data/docs/manual/plugins/rigor-activesupport-core-ext.md +86 -0
- data/docs/manual/plugins/rigor-devise.md +70 -0
- data/docs/manual/plugins/rigor-dry-schema.md +56 -0
- data/docs/manual/plugins/rigor-dry-struct.md +60 -0
- data/docs/manual/plugins/rigor-dry-types.md +59 -0
- data/docs/manual/plugins/rigor-dry-validation.md +62 -0
- data/docs/manual/plugins/rigor-factorybot.md +76 -0
- data/docs/manual/plugins/rigor-graphql.md +89 -0
- data/docs/manual/plugins/rigor-hanami.md +83 -0
- data/docs/manual/plugins/rigor-mangrove.md +73 -0
- data/docs/manual/plugins/rigor-minitest.md +86 -0
- data/docs/manual/plugins/rigor-pundit.md +72 -0
- data/docs/manual/plugins/rigor-rails-i18n.md +92 -0
- data/docs/manual/plugins/rigor-rails-routes.md +94 -0
- data/docs/manual/plugins/rigor-rails.md +44 -0
- data/docs/manual/plugins/rigor-rbs-inline.md +83 -0
- data/docs/manual/plugins/rigor-rspec-rails.md +72 -0
- data/docs/manual/plugins/rigor-rspec.md +86 -0
- data/docs/manual/plugins/rigor-shoulda-matchers.md +78 -0
- data/docs/manual/plugins/rigor-sidekiq.md +78 -0
- data/docs/manual/plugins/rigor-sinatra.md +61 -0
- data/docs/manual/plugins/rigor-sorbet.md +63 -0
- data/docs/manual/plugins/rigor-statesman.md +75 -0
- data/docs/manual/plugins/rigor-typescript-utility-types.md +71 -0
- data/exe/rigor +1 -1
- data/lib/rigor/analysis/incremental_session.rb +4 -2
- data/lib/rigor/analysis/run_stats.rb +13 -1
- data/lib/rigor/analysis/runner.rb +54 -12
- data/lib/rigor/cli/check_command.rb +1 -1
- data/lib/rigor/cli/docs_command.rb +248 -0
- data/lib/rigor/cli/skill_command.rb +103 -41
- data/lib/rigor/cli/skill_describe.rb +346 -0
- data/lib/rigor/cli/triage_command.rb +8 -2
- data/lib/rigor/cli/triage_renderer.rb +4 -0
- data/lib/rigor/cli.rb +25 -3
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +124 -32
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +37 -6
- data/lib/rigor/inference/scope_indexer.rb +87 -89
- data/lib/rigor/plugin/isolation.rb +5 -5
- data/lib/rigor/plugin/loader.rb +4 -2
- data/lib/rigor/triage/catalogue.rb +16 -1
- data/lib/rigor/triage.rb +30 -7
- data/lib/rigor/version.rb +1 -1
- data/skills/rigor-ask/SKILL.md +172 -0
- data/skills/rigor-doctor/SKILL.md +87 -0
- data/skills/rigor-editor-setup/SKILL.md +114 -0
- data/skills/rigor-mcp-setup/SKILL.md +117 -0
- data/skills/rigor-monkeypatch-resolve/SKILL.md +79 -0
- data/skills/rigor-next-steps/SKILL.md +113 -0
- data/skills/rigor-plugin-tune/SKILL.md +79 -0
- data/skills/rigor-protection-uplift/SKILL.md +133 -0
- data/skills/rigor-rbs-setup/SKILL.md +128 -0
- data/skills/rigor-upgrade/SKILL.md +79 -0
- metadata +90 -1
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# Coexisting with Sorbet
|
|
2
|
+
|
|
3
|
+
If your project already uses [Sorbet](https://sorbet.org/),
|
|
4
|
+
the [`rigor-sorbet`](../../plugins/rigor-sorbet/) plugin
|
|
5
|
+
lets Rigor read your existing `sig` blocks, RBI files, and
|
|
6
|
+
`T.let` / `T.cast` / `T.must` / `T.unsafe` assertions as type
|
|
7
|
+
sources. You do not have to rewrite anything in RBS to start
|
|
8
|
+
running `rigor check` alongside `srb tc`.
|
|
9
|
+
|
|
10
|
+
This chapter is for users arriving from a Sorbet-using
|
|
11
|
+
project. If you have never used Sorbet, you can skip it; the
|
|
12
|
+
core handbook material in chapters 1–9 covers Rigor's native
|
|
13
|
+
RBS-based path.
|
|
14
|
+
|
|
15
|
+
## What gets translated
|
|
16
|
+
|
|
17
|
+
Given a method preceded by a `sig` block:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
class Slug
|
|
21
|
+
extend T::Sig
|
|
22
|
+
|
|
23
|
+
sig { params(name: String).returns(String) }
|
|
24
|
+
def normalise(name)
|
|
25
|
+
name.downcase.gsub(/\s+/, "-")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sig { returns(Integer) }
|
|
29
|
+
def self.default_length
|
|
30
|
+
32
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Rigor lifts the parsed sig at every call site, so chained
|
|
36
|
+
calls resolve through the analyzer's normal dispatch:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
slug = Slug.new
|
|
40
|
+
slug.normalise("Alice").upcase # ✓ String#upcase resolves
|
|
41
|
+
Slug.default_length.even? # ✓ Integer#even? resolves
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
No `.rbs` file required. The plugin walks every Ruby file
|
|
45
|
+
under `paths:` (and every `.rbi` file under `sorbet/rbi/` —
|
|
46
|
+
see "RBI files" below), pairs each `sig { ... }` block with
|
|
47
|
+
the `def` immediately following it, and contributes the
|
|
48
|
+
return type at the matching call sites.
|
|
49
|
+
|
|
50
|
+
## The Sorbet type vocabulary
|
|
51
|
+
|
|
52
|
+
The plugin translates the dense middle of Sorbet's type DSL.
|
|
53
|
+
Most everyday sigs land precisely; rare or
|
|
54
|
+
class-introspection-heavy forms degrade to `Dynamic[top]`.
|
|
55
|
+
|
|
56
|
+
| Sorbet form | Rigor representation |
|
|
57
|
+
| ------------------------ | ---------------------------------------- |
|
|
58
|
+
| `Integer` etc. | `Nominal["Integer"]` |
|
|
59
|
+
| `::Foo::Bar` | `Nominal["Foo::Bar"]` |
|
|
60
|
+
| `T.untyped` | `Dynamic[top]` |
|
|
61
|
+
| `T.anything` | `Top` |
|
|
62
|
+
| `T.noreturn` | `Bot` |
|
|
63
|
+
| `T.nilable(X)` | `Union[X, Constant<nil>]` |
|
|
64
|
+
| `T.any(A, B, ...)` | `Union[A, B, ...]` |
|
|
65
|
+
| `T.all(A, B, ...)` | `Intersection[A, B, ...]` |
|
|
66
|
+
| `T::Boolean` | `Union[Constant<true>, Constant<false>]` |
|
|
67
|
+
| `T::Array[E]` | `Nominal["Array", [E]]` |
|
|
68
|
+
| `T::Hash[K, V]` | `Nominal["Hash", [K, V]]` |
|
|
69
|
+
| `T::Set[E]` | `Nominal["Set", [E]]` |
|
|
70
|
+
| `T::Range[E]` | `Nominal["Range", [E]]` |
|
|
71
|
+
| `T::Enumerable[E]` | `Nominal["Enumerable", [E]]` |
|
|
72
|
+
| `T::Class[T]` | `Singleton[T-class-name]` (lossy) |
|
|
73
|
+
| `T.class_of(C)` | `Singleton[C]` |
|
|
74
|
+
| `[A, B]` (tuple in sig) | `Tuple[A, B]` |
|
|
75
|
+
| `{a: A, b: B}` | `HashShape{a: A, b: B}` (closed) |
|
|
76
|
+
|
|
77
|
+
Anything outside this table — `T.proc`, `T.attached_class`,
|
|
78
|
+
`T.self_type`, `T.type_parameter`, `T::Struct` / `T::Enum`
|
|
79
|
+
subclasses — silently degrades to `Dynamic[top]` for now.
|
|
80
|
+
|
|
81
|
+
## Inline type assertions
|
|
82
|
+
|
|
83
|
+
Sorbet's `T.let` / `T.cast` / `T.must` / `T.unsafe`
|
|
84
|
+
expressions are recognised at every call site, not only inside
|
|
85
|
+
`sig` blocks:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
counter = T.let(0, Integer) # widens Constant<0> to Integer
|
|
89
|
+
counter.even? # ✓ Integer#even? resolves
|
|
90
|
+
|
|
91
|
+
T.cast(some_value, String).upcase # ✓ String#upcase resolves
|
|
92
|
+
|
|
93
|
+
maybe = T.let(nil, T.nilable(Integer))
|
|
94
|
+
T.must(maybe).bit_length # ✓ nil stripped → Integer
|
|
95
|
+
# then Integer#bit_length resolves
|
|
96
|
+
|
|
97
|
+
T.unsafe(opaque).any_method_at_all # ✓ silenced — return is Dynamic[top]
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`T.must_because(expr, "explanation")` is recognised as an
|
|
101
|
+
alias of `T.must` — the static behaviour is identical (strip
|
|
102
|
+
`nil`); the second-argument string is informational only.
|
|
103
|
+
|
|
104
|
+
`T.reveal_type(expr)` returns `expr` unchanged at runtime AND
|
|
105
|
+
surfaces the inferred static type as a
|
|
106
|
+
`plugin.sorbet.reveal-type` `:info` diagnostic at the call
|
|
107
|
+
site, so chained calls keep working while you eyeball what
|
|
108
|
+
the analyzer sees:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
n = T.let(3, Integer)
|
|
112
|
+
T.reveal_type(n).even? # info: T.reveal_type inferred type: Integer
|
|
113
|
+
# ✓ Integer#even? still resolves
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`T.assert_type!(expr, T)` is `T.cast` plus a static subtype
|
|
117
|
+
check. The call returns the asserted type so chained calls
|
|
118
|
+
resolve through it; if the inferred type is provably
|
|
119
|
+
incompatible (`Inference::Acceptance.accepts(...)` returns
|
|
120
|
+
`:no`), the plugin emits `plugin.sorbet.assert-type-mismatch`
|
|
121
|
+
as `:error`. Gradual consistency rules apply — `Dynamic[top]`
|
|
122
|
+
inferred types and `:maybe`-compatible shapes are silenced
|
|
123
|
+
because the runtime check covers them.
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
T.assert_type!("hello", Integer) # error: provably incompatible
|
|
127
|
+
T.assert_type!(some_obj, String) # silent: trust the user
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`T.bind(self, T)` narrows `self` to `T` for the rest of the
|
|
131
|
+
current scope (typically a block body):
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
arr.each do |x|
|
|
135
|
+
T.bind(self, MyHelper)
|
|
136
|
+
do_something(x) # ✓ self is now MyHelper for the rest of this block
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The narrowing is implemented via the engine's plugin-side
|
|
141
|
+
`post_return_facts` wiring — the same substrate any future
|
|
142
|
+
PHPStan-style Type-Specifying Extension plugin would use to
|
|
143
|
+
narrow argument variables after a custom assertion call.
|
|
144
|
+
|
|
145
|
+
`T.bind` rejects non-`self` first arguments silently (matches
|
|
146
|
+
Sorbet's contract — bind is self-only).
|
|
147
|
+
|
|
148
|
+
## RBI files
|
|
149
|
+
|
|
150
|
+
The plugin walks `sorbet/rbi/**/*.rbi` recursively by default
|
|
151
|
+
and treats each `.rbi` as Ruby source. The standard Tapioca
|
|
152
|
+
subdirectories (`gems/`, `annotations/`, `dsl/`, `shims/`)
|
|
153
|
+
all participate as a side effect of recursing into the parent
|
|
154
|
+
root. Override the location via `config.rbi_paths:` in
|
|
155
|
+
`.rigor.yml`, or set it to `[]` to opt out:
|
|
156
|
+
|
|
157
|
+
```yaml
|
|
158
|
+
plugins:
|
|
159
|
+
- gem: rigor-sorbet
|
|
160
|
+
config:
|
|
161
|
+
rbi_paths: [] # disable RBI loading
|
|
162
|
+
# rbi_paths: ["sorbet/rbi", "vendor/rbi"] # add a vendored tree
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Project sigs (`.rb` files under `paths:`) and RBI sigs
|
|
166
|
+
(`.rbi` files under `rbi_paths:`) feed the same per-run
|
|
167
|
+
catalog, so a method declared in either source resolves the
|
|
168
|
+
same way at the call site.
|
|
169
|
+
|
|
170
|
+
## Sorbet `# typed:` sigils
|
|
171
|
+
|
|
172
|
+
The plugin reads Sorbet's `# typed:` magic comment from the
|
|
173
|
+
top of each file. Behaviour depends on the `enforce_sigil`
|
|
174
|
+
config knob (default `true`):
|
|
175
|
+
|
|
176
|
+
| Sigil | `enforce_sigil: true` (default) | `enforce_sigil: false` |
|
|
177
|
+
| ------------------- | ------------------------------------------- | ---------------------- |
|
|
178
|
+
| `# typed: ignore` | Skipped entirely; no sigs / parse errors recorded. | Same. |
|
|
179
|
+
| no sigil / `false` | Walked for parse-error diagnostics, but sigs are NOT recorded. | Sigs recorded. |
|
|
180
|
+
| `# typed: true`+ | Sigs recorded. | Sigs recorded. |
|
|
181
|
+
|
|
182
|
+
The default mirrors Sorbet's own contract: types aren't
|
|
183
|
+
enforced at `# typed: false`, so Rigor doesn't surface
|
|
184
|
+
narrowing from those files either. Set `enforce_sigil: false`
|
|
185
|
+
in the plugin config to opt into the pre-gate behaviour
|
|
186
|
+
(every parseable file's sigs land in the catalog regardless
|
|
187
|
+
of sigil).
|
|
188
|
+
|
|
189
|
+
**Assertion recognisers** (`T.let`, `T.cast`, `T.must`,
|
|
190
|
+
`T.must_because`, `T.unsafe`, `T.reveal_type`,
|
|
191
|
+
`T.assert_type!`, `T.bind`) are NOT gated by
|
|
192
|
+
`enforce_sigil`. The user wrote those calls deliberately, so
|
|
193
|
+
they fire regardless of the file's sigil.
|
|
194
|
+
|
|
195
|
+
Sorbet-strict's "every method must have a sig" requirement
|
|
196
|
+
and strong-mode's `T.untyped` rejection are intentionally NOT
|
|
197
|
+
mirrored. Those checks live with `srb tc`. Rigor's own
|
|
198
|
+
`severity_profile` setting in `.rigor.yml` covers the
|
|
199
|
+
analogous filtering.
|
|
200
|
+
|
|
201
|
+
## Tapioca DSL — the mixin pattern
|
|
202
|
+
|
|
203
|
+
Tapioca's standard DSL RBI shape declares sigs on a generated
|
|
204
|
+
module that is `include`d / `extend`ed into the host class:
|
|
205
|
+
|
|
206
|
+
```rbi
|
|
207
|
+
class Post
|
|
208
|
+
include GeneratedAttributeMethods
|
|
209
|
+
module GeneratedAttributeMethods
|
|
210
|
+
sig { returns(::String) }
|
|
211
|
+
def body; end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The plugin records the sig under the module's qualified name
|
|
217
|
+
during the walk and lifts it to the host class at lookup
|
|
218
|
+
time. So `post.body` correctly resolves through
|
|
219
|
+
`Post::GeneratedAttributeMethods#body` — no manual
|
|
220
|
+
flattening required, and the same trick works for
|
|
221
|
+
hand-written shims under `sorbet/rbi/shims/` and community
|
|
222
|
+
annotations under `rbi-central`.
|
|
223
|
+
|
|
224
|
+
`extend M` correctly lifts `M`'s instance methods to the
|
|
225
|
+
extending class's singleton side, matching Ruby's runtime
|
|
226
|
+
behaviour:
|
|
227
|
+
|
|
228
|
+
```rbi
|
|
229
|
+
class Post
|
|
230
|
+
extend GeneratedClassMethods
|
|
231
|
+
module GeneratedClassMethods
|
|
232
|
+
sig { params(id: Integer).returns(Post) }
|
|
233
|
+
def find(id); end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
`Post.find(42)` resolves through the extended module's
|
|
239
|
+
instance side.
|
|
240
|
+
|
|
241
|
+
## `T.absurd` exhaustiveness
|
|
242
|
+
|
|
243
|
+
`T.absurd(x)` is Sorbet's idiom for case/when exhaustiveness:
|
|
244
|
+
"if I got here, the type system has lost the plot." The
|
|
245
|
+
plugin treats every `T.absurd` call as `Bot` (the empty
|
|
246
|
+
type — no possible value) AND raising, so the engine's
|
|
247
|
+
existing flow analysis treats code after the call as
|
|
248
|
+
unreachable:
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
case x
|
|
252
|
+
when A then handle_a(x)
|
|
253
|
+
when B then handle_b(x)
|
|
254
|
+
else
|
|
255
|
+
T.absurd(x) # asserts the else branch is unreachable
|
|
256
|
+
end
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
When the discriminant is fully exhausted, the `T.absurd`
|
|
260
|
+
call sits in dead code and contributes nothing. When a case
|
|
261
|
+
branch is missing, the discriminant's type at the `T.absurd`
|
|
262
|
+
call still has admissible inhabitants, and the plugin
|
|
263
|
+
surfaces `plugin.sorbet.absurd-reachable` as a warning:
|
|
264
|
+
|
|
265
|
+
```text
|
|
266
|
+
demo.rb:42:5: warning: `T.absurd` is reachable: the discriminant did not
|
|
267
|
+
narrow to `T.noreturn`. Either add the missing case
|
|
268
|
+
branch above the `else`, or remove the `T.absurd(...)` call.
|
|
269
|
+
[plugin.sorbet.absurd-reachable]
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The detection's accuracy follows Rigor's flow-sensitive
|
|
273
|
+
narrowing — `is_a?` / `kind_of?` / `nil?` work precisely;
|
|
274
|
+
narrowing over symbol enums is less precise as of v0.1.3,
|
|
275
|
+
so fully-exhausted symbol cases may emit false-positive
|
|
276
|
+
warnings until the engine's case narrowing improves.
|
|
277
|
+
|
|
278
|
+
## Tier ordering — what wins on conflict
|
|
279
|
+
|
|
280
|
+
When a method has both a Sorbet `sig` and an RBS sig, RBS
|
|
281
|
+
wins. Sorbet sigs sit at Rigor's plugin tier:
|
|
282
|
+
|
|
283
|
+
1. **Precision tiers** — constant fold, shape dispatch,
|
|
284
|
+
block fold, etc.
|
|
285
|
+
2. **Plugin contributions** — including `rigor-sorbet`'s
|
|
286
|
+
sig and assertion translations.
|
|
287
|
+
3. **RBS-backed dispatch** — project `sig/`,
|
|
288
|
+
`RBS::Inline`, bundled stdlib.
|
|
289
|
+
4. **Dependency-source inference** (ADR-10's opt-in walker).
|
|
290
|
+
5. **User-class fallback** (`Object` / `Class` ancestors).
|
|
291
|
+
|
|
292
|
+
The contribution merger (a v0.1.0 substrate documented in
|
|
293
|
+
[`docs/internal-spec/flow-contribution-merger.md`](../internal-spec/flow-contribution-merger.md))
|
|
294
|
+
keeps RBS authoritative on conflict — the Sorbet sig is
|
|
295
|
+
allowed to refine but not contradict it. Users who want
|
|
296
|
+
their Sorbet sig to override should remove the conflicting
|
|
297
|
+
RBS, not the other way around. The reverse direction
|
|
298
|
+
(Sorbet wins) would let third-party-DSL annotations
|
|
299
|
+
override authored RBS, which inverts the trust model.
|
|
300
|
+
|
|
301
|
+
## Migration patterns
|
|
302
|
+
|
|
303
|
+
The plugin is designed for **gradual coexistence**, not a
|
|
304
|
+
forced migration. Three common shapes:
|
|
305
|
+
|
|
306
|
+
1. **Run both static checkers side by side.** `srb tc`
|
|
307
|
+
keeps producing its diagnostics; `rigor check`
|
|
308
|
+
produces its own. They overlap on shape errors and
|
|
309
|
+
complement each other on what each finds — Sorbet
|
|
310
|
+
covers `T.let` / `T.cast` / RBI more deeply; Rigor
|
|
311
|
+
covers literal-string narrowing, refinement carriers,
|
|
312
|
+
plugin DSLs, and dependency-source inference.
|
|
313
|
+
2. **Sorbet for sigs, Rigor for narrowing.** Authoritative
|
|
314
|
+
sigs stay in `sig { ... }` blocks (or the
|
|
315
|
+
sorbet-runtime-friendly RBI tree); Rigor reads them as
|
|
316
|
+
input and adds its own narrowing on top.
|
|
317
|
+
3. **Sorbet → RBS over time.** New code lands as RBS;
|
|
318
|
+
existing Sorbet sigs stay until the surrounding
|
|
319
|
+
subsystem changes. The plugin keeps running while the
|
|
320
|
+
Sorbet surface shrinks.
|
|
321
|
+
|
|
322
|
+
## What the plugin doesn't replace
|
|
323
|
+
|
|
324
|
+
Rigor's `rigor-sorbet` adapter is **input-side only**. It
|
|
325
|
+
reads Sorbet's syntax and translates the vocabulary; it does
|
|
326
|
+
not run Sorbet's type checker, doesn't ship
|
|
327
|
+
`sorbet-runtime`, and doesn't enforce Sorbet's runtime
|
|
328
|
+
guarantees. If you remove `sorbet` and `sorbet-runtime` from
|
|
329
|
+
your `Gemfile`, the plugin keeps reading the sigs (the
|
|
330
|
+
adapter's mini-interpreter doesn't load Sorbet) but `T.let` /
|
|
331
|
+
`T.cast` / `T.must` / `T.unsafe` calls will raise
|
|
332
|
+
`NameError` at runtime unless you keep at least the runtime
|
|
333
|
+
gem (or stub the four singleton methods on a top-level `T`
|
|
334
|
+
constant — the plugin's demo does this for its own
|
|
335
|
+
unit tests).
|
|
336
|
+
|
|
337
|
+
## Where to go next
|
|
338
|
+
|
|
339
|
+
- The full feature matrix and architectural surface live in
|
|
340
|
+
[`plugins/rigor-sorbet/README.md`](../../plugins/rigor-sorbet/README.md).
|
|
341
|
+
- The design rationale + slice plan is at
|
|
342
|
+
[`docs/adr/11-sorbet-input-adapter.md`](../adr/11-sorbet-input-adapter.md).
|
|
343
|
+
- The cross-checker triage report at
|
|
344
|
+
[`docs/notes/20260503-steep-cross-check-triage.md`](../notes/20260503-steep-cross-check-triage.md)
|
|
345
|
+
shows how Rigor's analyzer routinely surfaces sig drift
|
|
346
|
+
that other static checkers miss — useful when comparing
|
|
347
|
+
what each tool finds in practice.
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# Generating RBS with rigor sig-gen
|
|
2
|
+
|
|
3
|
+
When `rigor check` is happy with your code but `sig/` is still
|
|
4
|
+
mostly empty, the analyzer is doing useful inference that
|
|
5
|
+
never reaches anyone but itself. `rigor sig-gen` is the
|
|
6
|
+
companion command that emits the inferred signatures as RBS
|
|
7
|
+
so the rest of the toolchain — Steep cross-checks, IDE
|
|
8
|
+
tooltips, downstream consumers reading your gem's `sig/` —
|
|
9
|
+
sees what Rigor sees.
|
|
10
|
+
|
|
11
|
+
This chapter is a walkthrough of the command's UX, the
|
|
12
|
+
classification model, the three output modes, and the
|
|
13
|
+
`--params` policy trade-off that comes straight out of
|
|
14
|
+
[ADR-5](../adr/5-robustness-principle.md)'s asymmetric
|
|
15
|
+
"strict on returns, lenient on parameters" rule.
|
|
16
|
+
|
|
17
|
+
## When to reach for it
|
|
18
|
+
|
|
19
|
+
- You inherited a Ruby project with zero RBS coverage and
|
|
20
|
+
want a starting point that is more honest than `rbs
|
|
21
|
+
prototype rb`'s syntactic skeleton.
|
|
22
|
+
- You added a method, `rigor check` recognises it, and now
|
|
23
|
+
you want the corresponding sig file updated without
|
|
24
|
+
retyping the signature by hand.
|
|
25
|
+
- Your existing RBS declares `() -> Numeric` but Rigor
|
|
26
|
+
proves `() -> Integer`. You want the tighter spelling
|
|
27
|
+
applied to `sig/` (after review).
|
|
28
|
+
|
|
29
|
+
What it is **not**: a replacement for hand-authored RBS
|
|
30
|
+
that captures intent the source code does not. If a public
|
|
31
|
+
method should accept `_ToStr` because the contract is
|
|
32
|
+
"anything that responds to `to_s`" but the current callers
|
|
33
|
+
only happen to pass `String`, `sig-gen` will not invent
|
|
34
|
+
`_ToStr` for you — the [`--params` policy](#the---params-policy-and-adr-5)
|
|
35
|
+
section below and ADR-5 explain why.
|
|
36
|
+
|
|
37
|
+
## A first run
|
|
38
|
+
|
|
39
|
+
Given a `lib/calc.rb`:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
class Calc
|
|
43
|
+
def add(a, b)
|
|
44
|
+
"sum"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def greet(name)
|
|
48
|
+
"hi"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
and an empty `sig/`, `rigor sig-gen` prints RBS skeletons:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
$ rigor sig-gen
|
|
57
|
+
# lib/calc.rb
|
|
58
|
+
class Calc
|
|
59
|
+
# [new]
|
|
60
|
+
def add: (untyped, untyped) -> String
|
|
61
|
+
# [new]
|
|
62
|
+
def greet: (untyped) -> String
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
By default the command writes nothing — it prints the
|
|
67
|
+
proposal so you can review it. Pass `--write` to apply the
|
|
68
|
+
proposal to `sig/`.
|
|
69
|
+
|
|
70
|
+
## The three output modes
|
|
71
|
+
|
|
72
|
+
| Mode | Behaviour |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| `--print` (default) | Print RBS to stdout, grouped by source file + class declaration. |
|
|
75
|
+
| `--diff` | Show a unified-style diff comparing the existing-declared spelling (if any) against the inferred spelling. Read-only. |
|
|
76
|
+
| `--write` | Apply the proposal to `sig/<path>.rbs`. Creates files, inserts new methods into existing class declarations, appends new class blocks to files that don't declare them yet. |
|
|
77
|
+
|
|
78
|
+
`--write` is the only mode that touches the filesystem. It
|
|
79
|
+
operates **only** inside `configuration.signature_paths`
|
|
80
|
+
(default `sig/`); anything outside that tree is reported as
|
|
81
|
+
`skipped_outside_sig_root` without being written to.
|
|
82
|
+
|
|
83
|
+
## The classification model
|
|
84
|
+
|
|
85
|
+
Every method `rigor sig-gen` considers lands in one of five
|
|
86
|
+
states:
|
|
87
|
+
|
|
88
|
+
| Classification | Meaning |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| `new-file` | No RBS file declares the receiver class at all. |
|
|
91
|
+
| `new-method` | RBS file declares the class but not this method. |
|
|
92
|
+
| `tighter-return` | RBS file declares the method, but the inferred return is a strict subtype of the declared return. |
|
|
93
|
+
| `equivalent` | The inferred return is not a strict subtype of the declared one — identical, wider, or unrelated — so there is nothing to tighten. Silently skipped. |
|
|
94
|
+
| `skipped` | Disqualified for one of the reasons below. |
|
|
95
|
+
|
|
96
|
+
The three `sig.skipped.*` reasons are:
|
|
97
|
+
|
|
98
|
+
- `sig.skipped.complex-shape` — the method has optional, rest,
|
|
99
|
+
keyword, block, or forwarding parameters. The MVP's
|
|
100
|
+
body-typing path only handles required positional
|
|
101
|
+
parameters; complex shapes need a future slice.
|
|
102
|
+
- `sig.skipped.untyped-return` — the method body's last
|
|
103
|
+
expression types as `Dynamic[top]`. Emitting `untyped` as
|
|
104
|
+
a tightening would be noise rather than help.
|
|
105
|
+
- `sig.skipped.user-authored` — `--overwrite` was not set
|
|
106
|
+
and the method's existing RBS declaration would have to
|
|
107
|
+
be replaced.
|
|
108
|
+
|
|
109
|
+
The three `sig.generated.*` identifiers
|
|
110
|
+
(`sig.generated.new-file` / `new-method` / `tighter-return`)
|
|
111
|
+
are emitted as JSON fields under `--format=json` so CI
|
|
112
|
+
gating consumers can route them.
|
|
113
|
+
|
|
114
|
+
## What method shapes the generator covers
|
|
115
|
+
|
|
116
|
+
Slice-by-slice (each shipped via a CHANGELOG entry — this
|
|
117
|
+
list is the current state):
|
|
118
|
+
|
|
119
|
+
- **Plain instance `def foo`** with required positional
|
|
120
|
+
parameters. Both new-method and tighter-return paths
|
|
121
|
+
apply.
|
|
122
|
+
- **Singleton-side `def self.foo`** and
|
|
123
|
+
`class << self; def foo; end`. Rendered as
|
|
124
|
+
`def self.foo: ...`; matched against
|
|
125
|
+
`Reflection.singleton_method_definition` for existing
|
|
126
|
+
RBS.
|
|
127
|
+
- **`attr_reader` / `attr_writer` / `attr_accessor`** with
|
|
128
|
+
literal Symbol arguments. The return type is the
|
|
129
|
+
accumulated ivar type from `Scope#class_ivars_for`. The
|
|
130
|
+
generator emits the long-form `def name: () -> T`
|
|
131
|
+
spelling so the writer's merge path applies unchanged;
|
|
132
|
+
existing short-form `attr_reader name: T` declarations
|
|
133
|
+
are recognised as user-authored and never produce a
|
|
134
|
+
duplicate `def` insertion.
|
|
135
|
+
|
|
136
|
+
Method shapes the generator does **not** cover yet (and
|
|
137
|
+
silently skips):
|
|
138
|
+
|
|
139
|
+
- Optional / rest / keyword / block / forwarding parameters.
|
|
140
|
+
- `define_method(:name) { ... }`.
|
|
141
|
+
- Methods whose body types as `Dynamic[top]` (the body
|
|
142
|
+
inference cannot prove a useful return type).
|
|
143
|
+
|
|
144
|
+
These are tracked as ADR-14 follow-ups.
|
|
145
|
+
|
|
146
|
+
## The `--params` policy and ADR-5
|
|
147
|
+
|
|
148
|
+
The `--params=POLICY` flag controls how parameter positions
|
|
149
|
+
are spelled in the emitted RBS. There are three policies;
|
|
150
|
+
two are wired today, one is reserved.
|
|
151
|
+
|
|
152
|
+
| Policy | Behaviour |
|
|
153
|
+
| --- | --- |
|
|
154
|
+
| `untyped` (default) | Every parameter is spelled `untyped`. No inference-derived parameter contract is imposed on future callers. The user retains complete authorship over parameter typing. |
|
|
155
|
+
| `observed` | Collect argument types from every call site under `--observe=PATH...` (defaults to `spec/` when present), union per parameter position, erase to RBS, emit the union. |
|
|
156
|
+
| `observed-strict` | Reserved. Will additionally widen to capability roles (`_ToStr`, `_ToS`, …) once the role catalog ships. Currently rejected with a usage error. |
|
|
157
|
+
|
|
158
|
+
The default deliberately favours `untyped` because of
|
|
159
|
+
[ADR-5](../adr/5-robustness-principle.md)'s clause 2: a
|
|
160
|
+
method's parameter contract should be the **most permissive**
|
|
161
|
+
shape the body's logic justifies, not the most specific
|
|
162
|
+
shape the current callers happen to use. Locking in
|
|
163
|
+
`observed` would silently freeze "what the existing specs
|
|
164
|
+
happen to pass" as the contract, which is the precision /
|
|
165
|
+
adoption trade-off the chapter introduction hinted at.
|
|
166
|
+
|
|
167
|
+
`--params=observed` is the deliberate opt-in: you are
|
|
168
|
+
saying *"the union of what my callers pass today IS the
|
|
169
|
+
parameter contract I want."* That is a correctness-
|
|
170
|
+
preserving widening — every existing caller still passes —
|
|
171
|
+
but it does narrow the contract relative to `untyped`.
|
|
172
|
+
|
|
173
|
+
## RSpec-aware observations
|
|
174
|
+
|
|
175
|
+
When you point `--observe` at a `spec/` directory, the
|
|
176
|
+
generator recognises three RSpec-shaped binding patterns
|
|
177
|
+
and uses them to type receivers that would otherwise
|
|
178
|
+
degrade to `Dynamic[top]`:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
RSpec.describe Calc do
|
|
182
|
+
subject { Calc.new } # binds :subject → Nominal[Calc]
|
|
183
|
+
let(:other) { Calc.new } # binds :other → Nominal[Calc]
|
|
184
|
+
|
|
185
|
+
it "..." do
|
|
186
|
+
subject.greet("Alice") # observed: Calc#greet receives String
|
|
187
|
+
other.greet("Bob") # observed: same
|
|
188
|
+
described_class.new.add(1, 2) # observed: Calc#add receives Integer, Integer
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The recogniser handles `RSpec.describe Foo`, bare
|
|
194
|
+
`describe Foo` (no `RSpec.` receiver), `subject { … }`,
|
|
195
|
+
`subject(:name) { … }`, `let(:name) { … }`, `let!(:name)`,
|
|
196
|
+
and `described_class.new(...)`. Same-name `let` bindings
|
|
197
|
+
across nested scopes are last-wins; the recogniser does not
|
|
198
|
+
re-implement RSpec's full scope rules — the typical
|
|
199
|
+
one-spec-file shape is the target.
|
|
200
|
+
|
|
201
|
+
The recogniser is part of the generator itself; you do not
|
|
202
|
+
need to install `rigor-rspec` to benefit from it. If you
|
|
203
|
+
already use `rigor-rspec` for diagnostics, the two run side
|
|
204
|
+
by side without coordination.
|
|
205
|
+
|
|
206
|
+
## Safety: what `--write` will and will not do
|
|
207
|
+
|
|
208
|
+
- **Will** create new `*.rbs` files mirroring `lib/<path>.rb`'s
|
|
209
|
+
layout (basename of `configuration.paths.first` stripped,
|
|
210
|
+
placed under `configuration.signature_paths.first`).
|
|
211
|
+
- **Will** insert new method declarations just before a
|
|
212
|
+
class declaration's closing `end` keyword, preserving
|
|
213
|
+
every other byte of the file verbatim.
|
|
214
|
+
- **Will** append a new `class Foo … end` block when the
|
|
215
|
+
target file does not declare the class yet.
|
|
216
|
+
- **Will not** touch files outside the configured signature
|
|
217
|
+
tree.
|
|
218
|
+
- **Will not** replace an existing method declaration
|
|
219
|
+
unless `--overwrite` is set AND the candidate is a
|
|
220
|
+
`tighter-return`. Without `--overwrite`, existing
|
|
221
|
+
declarations are user-authored and the new method is
|
|
222
|
+
silently skipped.
|
|
223
|
+
- **Will not** touch `attr_reader` / `attr_writer` /
|
|
224
|
+
`attr_accessor` declarations in existing RBS — those are
|
|
225
|
+
always treated as user-authored.
|
|
226
|
+
|
|
227
|
+
The recommended workflow is `--diff` first, review, then
|
|
228
|
+
`--write` (or `--write --overwrite` if you decided that
|
|
229
|
+
the tightening is intentional).
|
|
230
|
+
|
|
231
|
+
### When tightening is *probably* incomplete inference
|
|
232
|
+
|
|
233
|
+
The strict-subtype check is a *necessary* condition for
|
|
234
|
+
emitting a tighter-return — it's not a sufficient signal
|
|
235
|
+
that the existing RBS is wrong. Slice 1's body-typing path
|
|
236
|
+
only inspects the implicit-return expression, so a method
|
|
237
|
+
like:
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
def find(key)
|
|
241
|
+
return nil unless @table.key?(key)
|
|
242
|
+
@table[key]
|
|
243
|
+
end
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
types as the return of `@table[key]` alone. If the existing
|
|
247
|
+
RBS declares `(K) -> V | nil`, the inferred `V` looks
|
|
248
|
+
strictly tighter — but it's tighter for the wrong reason
|
|
249
|
+
(the `nil` branch is unreachable in the body-typer's eyes,
|
|
250
|
+
not in the runtime's). Applying it would silently delete
|
|
251
|
+
the `nil` arm.
|
|
252
|
+
|
|
253
|
+
**Heuristic**: when a tightening DROPS union members that
|
|
254
|
+
the existing RBS declares — `T | nil → T`, `false | true →
|
|
255
|
+
true`, `Float | Integer → Float`, `Array[T] → [T]` — treat
|
|
256
|
+
it as a contradiction signal, not a precision win, and
|
|
257
|
+
leave the existing RBS alone. The generator does not yet
|
|
258
|
+
classify these automatically; the `--diff` review step is
|
|
259
|
+
where the human gate sits.
|
|
260
|
+
|
|
261
|
+
For `rigor`'s own `sig/` tree this is the load-bearing
|
|
262
|
+
policy: every tighter-return that contradicts an existing
|
|
263
|
+
declaration is suspected incomplete inference until proven
|
|
264
|
+
otherwise.
|
|
265
|
+
|
|
266
|
+
## Putting it together
|
|
267
|
+
|
|
268
|
+
A typical iteration on a new file:
|
|
269
|
+
|
|
270
|
+
```sh
|
|
271
|
+
# 1. See what Rigor would propose.
|
|
272
|
+
rigor sig-gen lib/calc.rb
|
|
273
|
+
|
|
274
|
+
# 2. Run with the observed-params policy to use spec/ as
|
|
275
|
+
# a parameter-type signal.
|
|
276
|
+
rigor sig-gen --params=observed lib/calc.rb
|
|
277
|
+
|
|
278
|
+
# 3. Compare against the current sig/ tree.
|
|
279
|
+
rigor sig-gen --params=observed --diff lib/calc.rb
|
|
280
|
+
|
|
281
|
+
# 4. Apply.
|
|
282
|
+
rigor sig-gen --params=observed --write lib/calc.rb
|
|
283
|
+
|
|
284
|
+
# 5. Re-run rigor check to confirm no regressions.
|
|
285
|
+
rigor check
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
The five steps map to the five ADR-14 slices the command
|
|
289
|
+
is built from. If any step shows results you didn't expect,
|
|
290
|
+
the diagnostic the analyzer would emit for the same code is
|
|
291
|
+
the source of truth — `sig-gen` is a downstream consumer of
|
|
292
|
+
inference, not a separate analysis.
|
|
293
|
+
|
|
294
|
+
## Limits today
|
|
295
|
+
|
|
296
|
+
- Methods with optional / rest / keyword / block /
|
|
297
|
+
forwarding parameters silently skip
|
|
298
|
+
(`sig.skipped.complex-shape`).
|
|
299
|
+
- `define_method` and `Data.define`-specific emission are
|
|
300
|
+
deferred follow-ups (`Data.define`-derived readers come
|
|
301
|
+
through if a method body exists).
|
|
302
|
+
- The strict-subtype check uses gradual-mode acceptance
|
|
303
|
+
today; the `:strict` mode reserved on
|
|
304
|
+
`Inference::Acceptance` arrives in a follow-up.
|
|
305
|
+
- Round-trip through `RBS::Writer` is not used (it drops
|
|
306
|
+
comments by upstream design); the generator's
|
|
307
|
+
byte-range insertion preserves untouched declarations
|
|
308
|
+
verbatim but cannot preserve comments interleaved
|
|
309
|
+
*inside* a touched declaration's range.
|
|
310
|
+
|
|
311
|
+
These are the ADR-14 deferred items; the design rationale
|
|
312
|
+
is in [`docs/adr/14-rbs-sig-generation.md`](../adr/14-rbs-sig-generation.md).
|