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,321 @@
|
|
|
1
|
+
# Tuples and hash shapes
|
|
2
|
+
|
|
3
|
+
`Tuple` and `HashShape` are how Rigor gives precise types to
|
|
4
|
+
heterogeneous arrays and known-key hashes. They look a lot like
|
|
5
|
+
Ruby's `Array` and `Hash` from the outside (and erase to those
|
|
6
|
+
nominal types when crossing an RBS boundary), but inside Rigor
|
|
7
|
+
they carry the per-position / per-key types that ordinary
|
|
8
|
+
`Array[T]` / `Hash[K, V]` would lose.
|
|
9
|
+
|
|
10
|
+
## Tuples — heterogeneous arrays
|
|
11
|
+
|
|
12
|
+
When the analyzer can prove the layout of an array literal, it
|
|
13
|
+
produces `Tuple[…]` rather than `Array[T]`:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
arr = [1, "two", :three]
|
|
17
|
+
# Tuple[Constant<1>, Constant<"two">, Constant<:three>]
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The most common ways tuples appear in real code:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# Multiple-assignment destructuring is per-position.
|
|
24
|
+
first, second, third = [10, 20, 30]
|
|
25
|
+
assert_type("10", first)
|
|
26
|
+
assert_type("20", second)
|
|
27
|
+
assert_type("30", third)
|
|
28
|
+
|
|
29
|
+
# divmod returns a 2-tuple.
|
|
30
|
+
quotient, remainder = 17.divmod(5)
|
|
31
|
+
assert_type("3", quotient)
|
|
32
|
+
assert_type("2", remainder)
|
|
33
|
+
|
|
34
|
+
# Each-with-index yields a 2-tuple.
|
|
35
|
+
%w[a b c].each_with_index do |elt, idx|
|
|
36
|
+
assert_type("\"a\" | \"b\" | \"c\"", elt)
|
|
37
|
+
assert_type("non-negative-int", idx)
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Indexed access into a tuple stays per-position:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
arr = [1, "two", :three]
|
|
45
|
+
arr[0] # Constant<1>
|
|
46
|
+
arr[1] # Constant<"two">
|
|
47
|
+
arr[-1] # Constant<:three>
|
|
48
|
+
arr[5] # Constant<nil> — out of bounds
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Slicing with `[start, length]` or `[range]` produces a tuple
|
|
52
|
+
of the matching elements:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
arr = [10, 20, 30, 40, 50]
|
|
56
|
+
arr[1..3] # Tuple[Constant<20>, Constant<30>, Constant<40>]
|
|
57
|
+
arr[2, 2] # Tuple[Constant<30>, Constant<40>]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Tuples through `map`, `select`, and friends
|
|
61
|
+
|
|
62
|
+
When you call an Enumerable method on a tuple, Rigor evaluates
|
|
63
|
+
the block once per element with the per-position type
|
|
64
|
+
substituted, then unions the results:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
arr = [1, 2, 3]
|
|
68
|
+
doubled = arr.map { |n| n * 2 }
|
|
69
|
+
# Tuple[Constant<2>, Constant<4>, Constant<6>]
|
|
70
|
+
|
|
71
|
+
mixed = [1, "two", :three]
|
|
72
|
+
strings = mixed.map { |x| x.to_s }
|
|
73
|
+
# Tuple[Constant<"1">, Constant<"two">, Constant<"three">]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`select` and `filter_map` widen to `Array[Element]` because
|
|
77
|
+
the resulting size depends on the predicate, not the
|
|
78
|
+
positions. `find` returns the union of the elements (or `nil`
|
|
79
|
+
when no element matches statically).
|
|
80
|
+
|
|
81
|
+
## Tuples widen — when and why
|
|
82
|
+
|
|
83
|
+
A `Tuple` widens to `Array[T]` when its size grows past the
|
|
84
|
+
configurable union budget, when an unknown-shape array is
|
|
85
|
+
concatenated to it, or when it crosses an RBS-declared
|
|
86
|
+
parameter typed as `Array[T]`. The widening is deterministic
|
|
87
|
+
and documented in
|
|
88
|
+
[`docs/type-specification/inference-budgets.md`](../type-specification/inference-budgets.md).
|
|
89
|
+
|
|
90
|
+
Widening is safe (`Array[T]` is a strictly less precise view
|
|
91
|
+
of the same value), but you lose the per-position information.
|
|
92
|
+
If you find yourself writing code where `[a, b, c]` should
|
|
93
|
+
type-check precisely but does not, look for a method
|
|
94
|
+
in the chain that takes `Array[T]` rather than a tuple, or a
|
|
95
|
+
`+` / `concat` against a wider array.
|
|
96
|
+
|
|
97
|
+
## Hash shapes — known-key hashes
|
|
98
|
+
|
|
99
|
+
The hash analogue is `HashShape`:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
user = { name: "Alice", age: 30, admin: false }
|
|
103
|
+
# { name: "Alice", age: 30, admin: false }
|
|
104
|
+
|
|
105
|
+
assert_type("\"Alice\"", user[:name])
|
|
106
|
+
assert_type("30", user[:age])
|
|
107
|
+
assert_type("false", user[:admin])
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Hash shapes have a few extra dimensions over tuples:
|
|
111
|
+
|
|
112
|
+
- **Required vs optional keys.** Was the key written
|
|
113
|
+
unconditionally in the literal, or merged in conditionally?
|
|
114
|
+
- **Open vs closed.** Can the value carry extra keys beyond
|
|
115
|
+
the listed ones?
|
|
116
|
+
- **Read-only entries.** Has Rigor seen a write to the key, or
|
|
117
|
+
only reads?
|
|
118
|
+
|
|
119
|
+
Rigor tracks all three but exposes them mostly through the
|
|
120
|
+
narrowing rules — most users do not need to think about them
|
|
121
|
+
directly.
|
|
122
|
+
|
|
123
|
+
## Hash shapes through method calls
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
config = { host: "example.com", port: 8080 }
|
|
127
|
+
# HashShape{host: Constant<"example.com">, port: Constant<8080>}
|
|
128
|
+
|
|
129
|
+
config.fetch(:host) # Constant<"example.com">
|
|
130
|
+
config.fetch(:host, "x") # Constant<"example.com"> (default unused)
|
|
131
|
+
config[:port] # Constant<8080>
|
|
132
|
+
config.key?(:host) # Constant<true> — proven
|
|
133
|
+
config.empty? # Constant<false> — proven
|
|
134
|
+
config.size # Constant<2>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Keyword-argument hashes
|
|
138
|
+
|
|
139
|
+
When you call a method with keyword arguments, the implicit
|
|
140
|
+
hash shape is what Rigor type-checks against:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
def connect(host:, port: 80)
|
|
144
|
+
# ...
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
connect(host: "example.com") # OK (port defaults)
|
|
148
|
+
connect(host: "example.com", port: 80) # OK
|
|
149
|
+
connect(host: "example.com", port: "8080") # warning when
|
|
150
|
+
# port: Integer
|
|
151
|
+
# is required
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Hash shapes flow through `**` splat and double-splat
|
|
155
|
+
operations, so `connect(**opts)` where `opts` is a known
|
|
156
|
+
shape narrows correctly.
|
|
157
|
+
|
|
158
|
+
## Splat composition
|
|
159
|
+
|
|
160
|
+
Splatting one tuple into another preserves the per-position
|
|
161
|
+
information when the splat is at a fixed position:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
head = [1, 2]
|
|
165
|
+
tail = [3, 4]
|
|
166
|
+
arr = [*head, *tail]
|
|
167
|
+
# Tuple[Constant<1>, Constant<2>, Constant<3>, Constant<4>]
|
|
168
|
+
|
|
169
|
+
with_middle = [*head, "X", *tail]
|
|
170
|
+
# Tuple[Constant<1>, Constant<2>, Constant<"X">,
|
|
171
|
+
# Constant<3>, Constant<4>]
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Same for double-splat into hash shapes:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
defaults = { port: 80, ssl: false }
|
|
178
|
+
overrides = { port: 443, ssl: true }
|
|
179
|
+
final = { **defaults, **overrides }
|
|
180
|
+
# HashShape{port: Constant<443>, ssl: Constant<true>}
|
|
181
|
+
# (the override wins per Ruby semantics)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Pattern matching destructuring
|
|
185
|
+
|
|
186
|
+
`case x in [a, b, c]` narrows `a` / `b` / `c` per-position
|
|
187
|
+
exactly like multiple-assignment:
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
case [10, 20, 30]
|
|
191
|
+
in [first, _, third]
|
|
192
|
+
assert_type("Dynamic[top]", first) # pattern bindings are not constant-folded
|
|
193
|
+
assert_type("Dynamic[top]", third)
|
|
194
|
+
end
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Hash patterns work the same way:
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
case { name: "Alice", age: 30 }
|
|
201
|
+
in { name:, age: }
|
|
202
|
+
assert_type("Dynamic[top]", name) # pattern bindings are not constant-folded
|
|
203
|
+
assert_type("Dynamic[top]", age)
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
An alternation pattern (`Integer | String => x`) produces a
|
|
208
|
+
union for the captured local — see
|
|
209
|
+
[Chapter 3](03-narrowing.md) for the underlying narrowing
|
|
210
|
+
rule.
|
|
211
|
+
|
|
212
|
+
## When the layout is not provable
|
|
213
|
+
|
|
214
|
+
If even one element of an array literal has a non-Constant,
|
|
215
|
+
non-tuple-shaped type, Rigor falls back to `Array[T]` where
|
|
216
|
+
`T` is the union of element types — still useful, just not
|
|
217
|
+
per-position:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
arr = [1, ARGV.first]
|
|
221
|
+
# Array[Constant<1> | String?]
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The same goes for hashes whose keys are not provably symbol /
|
|
225
|
+
string literals — Rigor produces `Hash[K, V]` rather than
|
|
226
|
+
`HashShape`.
|
|
227
|
+
|
|
228
|
+
## Deriving new shapes — `pick_of` / `omit_of` / `partial_of` / `required_of` / `readonly_of`
|
|
229
|
+
|
|
230
|
+
When you have a `HashShape` (or a `Tuple`) and want a derived
|
|
231
|
+
shape that keeps some fields, drops others, or flips the
|
|
232
|
+
required-ness, Rigor exposes five **shape-projection type
|
|
233
|
+
functions** on `Type::Combinator`. They mirror TypeScript's
|
|
234
|
+
`Pick` / `Omit` / `Partial` / `Required` / `Readonly` utility
|
|
235
|
+
types but are first-class Rigor operations, not a TS bolt-on.
|
|
236
|
+
Each preserves the source's existing classification (required /
|
|
237
|
+
optional / read-only / extra-keys policy) on the entries it
|
|
238
|
+
keeps.
|
|
239
|
+
|
|
240
|
+
| Projection | What it does | TypeScript analogue |
|
|
241
|
+
| --- | --- | --- |
|
|
242
|
+
| `pick_of[T, K]` | Keep only the entries whose key is in the literal-key union `K`. On `Tuple`, `K` is an integer-index union. | `Pick<T, K>` |
|
|
243
|
+
| `omit_of[T, K]` | Drop the entries whose key is in `K`; keep the rest. | `Omit<T, K>` |
|
|
244
|
+
| `partial_of[T]` | Flip every required entry to optional. **Does not** widen value types to `nil` — Rigor distinguishes "key absent" from "key present with `nil` value". | `Partial<T>` |
|
|
245
|
+
| `required_of[T]` | Inverse of `partial_of`. Every optional entry becomes required. | `Required<T>` |
|
|
246
|
+
| `readonly_of[T]` | Mark every entry as read-only in the current view. Does NOT prove the underlying object is frozen — it is a view-level marker. | `Readonly<T>` |
|
|
247
|
+
|
|
248
|
+
These show up in two surfaces:
|
|
249
|
+
|
|
250
|
+
### As `RBS::Extended` directive payloads
|
|
251
|
+
|
|
252
|
+
The projection name is part of the directive grammar — the
|
|
253
|
+
parser accepts Symbol / String literals and `|`-unions inside
|
|
254
|
+
the type-arg position, so you can author the key set inline:
|
|
255
|
+
|
|
256
|
+
```rbs
|
|
257
|
+
class UserView
|
|
258
|
+
# The runtime returns the full user hash; the view exposes
|
|
259
|
+
# only :name and :email to its caller. The directive narrows
|
|
260
|
+
# the return-side HashShape to those two entries.
|
|
261
|
+
%a{rigor:v1:return: pick_of[UserHash, :name | :email]}
|
|
262
|
+
def public_attrs: () -> ::Hash[::Symbol, ::String]
|
|
263
|
+
end
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Inside an analysed file, the call site's result type is the
|
|
267
|
+
projected HashShape rather than the raw `Hash[Symbol, String]`
|
|
268
|
+
the underlying RBS sig advertises.
|
|
269
|
+
|
|
270
|
+
### Through the opt-in TypeScript-utility-types plugin
|
|
271
|
+
|
|
272
|
+
If you prefer the TS spellings (`Pick<T, K>` etc.) in
|
|
273
|
+
directives, opt into the
|
|
274
|
+
[`rigor-typescript-utility-types`](../../plugins/rigor-typescript-utility-types/)
|
|
275
|
+
plugin. The plugin registers a `Plugin::TypeNodeResolver` that
|
|
276
|
+
translates each TS name onto the canonical projection:
|
|
277
|
+
|
|
278
|
+
```yaml
|
|
279
|
+
# .rigor.yml
|
|
280
|
+
plugins:
|
|
281
|
+
- gem: rigor-typescript-utility-types
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
```rbs
|
|
285
|
+
%a{rigor:v1:return: Pick[UserHash, "name" | "email"]}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
The plugin chain resolves `Pick[…]` to `pick_of[…]` before the
|
|
289
|
+
analyzer sees it — the inferred result is identical to the
|
|
290
|
+
direct `pick_of` spelling. The plugin is purely a naming
|
|
291
|
+
convenience.
|
|
292
|
+
|
|
293
|
+
### Lossy projection
|
|
294
|
+
|
|
295
|
+
The projections fire only on carriers that preserve shape
|
|
296
|
+
information (`HashShape` and, for `pick_of` / `omit_of`,
|
|
297
|
+
`Tuple`). Applying them to a plain `Hash[K, V]` or any other
|
|
298
|
+
non-shape input is **lossy** — the projection silently
|
|
299
|
+
degrades to the input type and Rigor records a
|
|
300
|
+
[`dynamic.shape.lossy-projection`](../type-specification/diagnostic-policy.md)
|
|
301
|
+
`:info` diagnostic so you can audit the call site.
|
|
302
|
+
|
|
303
|
+
```rbs
|
|
304
|
+
class C
|
|
305
|
+
# `User` here is `Nominal[User]`, not a HashShape, so the
|
|
306
|
+
# projection cannot narrow anything. The directive is
|
|
307
|
+
# accepted but `:info` records the lossy degrade.
|
|
308
|
+
%a{rigor:v1:return: pick_of[User, :name]}
|
|
309
|
+
def render: () -> ::User
|
|
310
|
+
end
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
The fix is usually to author a `HashShape` carrier (or use
|
|
314
|
+
`Data.define` / a `Struct`) instead of a bare `Nominal`.
|
|
315
|
+
|
|
316
|
+
## What's next
|
|
317
|
+
|
|
318
|
+
Chapter 5 covers the function side: how Rigor types method
|
|
319
|
+
parameters and return values, how block parameters are bound
|
|
320
|
+
through Enumerable iteration, and how arity / parameter-type
|
|
321
|
+
mismatches surface as `call.*` diagnostics.
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
# Methods and blocks
|
|
2
|
+
|
|
3
|
+
This chapter covers what Rigor knows about method calls — the
|
|
4
|
+
receiver's type, the argument types, the inferred return
|
|
5
|
+
type, and the block parameters when a block is attached.
|
|
6
|
+
Several sections double as the reference for a call-site
|
|
7
|
+
diagnostic, so the rule IDs appear in the headings.
|
|
8
|
+
|
|
9
|
+
## Method dispatch — what Rigor sees at a call site
|
|
10
|
+
|
|
11
|
+
When Rigor encounters `receiver.method(args, &block)`, it
|
|
12
|
+
runs through a fixed sequence of dispatch tiers, taking the
|
|
13
|
+
first one that produces a result:
|
|
14
|
+
|
|
15
|
+
1. **Constant folding.** Every argument is a `Constant<...>`
|
|
16
|
+
or a tuple of constants, the receiver is a known
|
|
17
|
+
nominal class, and the method is in the per-class
|
|
18
|
+
"pure" catalog. Rigor invokes the method at lint time
|
|
19
|
+
and returns the result. `1 + 2` → `Constant<3>`,
|
|
20
|
+
`[1, 2, 3].first` → `Constant<1>`.
|
|
21
|
+
2. **Shape dispatch.** The receiver carries a `Tuple` /
|
|
22
|
+
`HashShape` / `IntegerRange` / refinement and the method
|
|
23
|
+
has a per-shape rule. `Tuple[A, B, C].size` →
|
|
24
|
+
`Constant<3>`; `int<0, max>.zero?` → `Constant<true> |
|
|
25
|
+
Constant<false>`.
|
|
26
|
+
3. **RBS dispatch.** The class has an RBS sig for the method.
|
|
27
|
+
Argument types are checked against the parameter contract
|
|
28
|
+
(more on this below); the return type is read from the sig
|
|
29
|
+
and may be tightened by `RBS::Extended` directives.
|
|
30
|
+
4. **In-source dispatch.** The class has no RBS but Rigor
|
|
31
|
+
discovered a `def` (or `define_method`, `attr_*`) in the
|
|
32
|
+
project. Parameter types are not checked (no contract);
|
|
33
|
+
the return type is inferred from the method body.
|
|
34
|
+
5. **Fallback.** None of the above — the call returns
|
|
35
|
+
`Dynamic[top]` and stays silent.
|
|
36
|
+
|
|
37
|
+
The cascading "first match wins" structure is why a method
|
|
38
|
+
with a tight RBS sig + an `RBS::Extended` directive overrides
|
|
39
|
+
the in-source body's inferred return type. Tightening at the
|
|
40
|
+
sig level is the supported way to teach Rigor about a
|
|
41
|
+
domain-specific method whose return type is narrower than
|
|
42
|
+
RBS expresses.
|
|
43
|
+
|
|
44
|
+
## Argument typing — `call.argument-type-mismatch`
|
|
45
|
+
|
|
46
|
+
When the method has an RBS sig (or an `RBS::Extended`
|
|
47
|
+
parameter override), Rigor checks each positional / keyword
|
|
48
|
+
argument against the declared parameter type:
|
|
49
|
+
|
|
50
|
+
```rbs
|
|
51
|
+
class Slug
|
|
52
|
+
%a{rigor:v1:param: id is non-empty-string}
|
|
53
|
+
def normalise: (::String id) -> ::String
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
Slug.new.normalise("hello") # OK — Constant<"hello"> accepted
|
|
59
|
+
# by non-empty-string
|
|
60
|
+
|
|
61
|
+
Slug.new.normalise("") # error: argument-type-mismatch
|
|
62
|
+
# ("" is the one value
|
|
63
|
+
# non-empty-string excludes)
|
|
64
|
+
|
|
65
|
+
Slug.new.normalise(some_str) # OK if Rigor cannot prove some_str
|
|
66
|
+
# is empty; Rigor stays silent on
|
|
67
|
+
# "could be either" cases.
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`call.argument-type-mismatch` only fires when Rigor can
|
|
71
|
+
**prove** the argument cannot satisfy the parameter contract.
|
|
72
|
+
"Possibly empty" stays silent — the no-false-positives rule.
|
|
73
|
+
|
|
74
|
+
## Arity — `call.wrong-arity`
|
|
75
|
+
|
|
76
|
+
When the receiver class is statically known and the method is
|
|
77
|
+
discoverable (RBS sig or in-source `def`), Rigor checks the
|
|
78
|
+
number of arguments against the method's arity:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
[1, 2, 3].rotate(1, 2)
|
|
82
|
+
# error: wrong number of arguments to `rotate' on Array
|
|
83
|
+
# (given 2, expected 0..1)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Arity checking respects optional positional, splat, keyword
|
|
87
|
+
arguments, and overload signatures. When the method is
|
|
88
|
+
overloaded, every overload that accepts the given arity is a
|
|
89
|
+
candidate — Rigor only flags arity when **no** overload
|
|
90
|
+
accepts.
|
|
91
|
+
|
|
92
|
+
## `call.undefined-method`
|
|
93
|
+
|
|
94
|
+
When the receiver class is statically known and the method is
|
|
95
|
+
not in any of (RBS sig, in-source `def`, in-source attr,
|
|
96
|
+
`Data.define` accessor), Rigor flags the call:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
"hello".no_such_method
|
|
100
|
+
# error: undefined method `no_such_method' for "hello"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The rule is **deliberately conservative**: a call only fires
|
|
104
|
+
when the receiver type is statically known and the method
|
|
105
|
+
catalogue is enumerable. `Dynamic[top]` receivers, implicit-
|
|
106
|
+
self calls inside method bodies, and constant-decl alias
|
|
107
|
+
classes (`YAML` → `Psych`) are silenced.
|
|
108
|
+
|
|
109
|
+
## `call.possible-nil-receiver`
|
|
110
|
+
|
|
111
|
+
When the receiver's type is `T | nil` and the method called
|
|
112
|
+
on it is not defined on `NilClass`, Rigor flags it:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
def shout(name)
|
|
116
|
+
name.upcase # warning if name: String?
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The fix is usually a guard:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
def shout(name)
|
|
124
|
+
return "" if name.nil?
|
|
125
|
+
name.upcase # name: String now
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
This rule is one of the highest-value diagnostics Rigor ships.
|
|
130
|
+
It catches the entire family of `NoMethodError on nil`
|
|
131
|
+
crashes that pepper any non-trivial Ruby code base.
|
|
132
|
+
|
|
133
|
+
## Return-type inference for in-source methods
|
|
134
|
+
|
|
135
|
+
When you write a `def` without an RBS sig, Rigor infers the
|
|
136
|
+
return type from the method body. The inferred type is
|
|
137
|
+
whatever the last expression evaluates to:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
def double(n)
|
|
141
|
+
n * 2
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
double(5) # Constant<10> — Rigor folds the call
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
When the body has multiple branches, the return type is the
|
|
148
|
+
union of every reachable terminal expression:
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
def kind(x)
|
|
152
|
+
if x.is_a?(Integer)
|
|
153
|
+
:int
|
|
154
|
+
elsif x.is_a?(String)
|
|
155
|
+
:str
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
kind(7) # Constant<:int>
|
|
160
|
+
kind("hi") # Constant<:str>
|
|
161
|
+
kind(:nope) # Constant<nil> — the implicit nil from
|
|
162
|
+
# the if's missing else branch
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`return` mid-body works as expected; explicit `raise` excludes
|
|
166
|
+
that branch from the union (a `bot` carrier internally).
|
|
167
|
+
|
|
168
|
+
### Recursive methods
|
|
169
|
+
|
|
170
|
+
A recursive method infers a precise return type rather than
|
|
171
|
+
collapsing to `Dynamic[top]`. When the recursive call has
|
|
172
|
+
fully-constant arguments, Rigor unrolls it (under a hard frame
|
|
173
|
+
budget) and folds the result:
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
def factorial(n)
|
|
177
|
+
n <= 1 ? 1 : n * factorial(n - 1)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
factorial(5) # Constant<120>
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
When the arguments are not constant, Rigor reaches a fixpoint
|
|
184
|
+
*return summary* instead — so a pass-through recursion returns
|
|
185
|
+
the right type rather than a contaminated one:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
def last_step(n)
|
|
189
|
+
n <= 0 ? :done : last_step(n - 1)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
last_step(some_int) # Constant<:done>
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Both paths are hard-capped and degrade to the previous
|
|
196
|
+
base-type behaviour when they cannot converge. Mutual
|
|
197
|
+
recursion across two methods terminates, degrading to a safe
|
|
198
|
+
`Dynamic[top]` floor where a precise summary is unavailable.
|
|
199
|
+
|
|
200
|
+
## `def.return-type-mismatch`
|
|
201
|
+
|
|
202
|
+
When a method has both an RBS-declared return type and an
|
|
203
|
+
inferred one, Rigor checks that the inferred fits the
|
|
204
|
+
declared:
|
|
205
|
+
|
|
206
|
+
```rbs
|
|
207
|
+
class Slug
|
|
208
|
+
def normalise: (::String) -> ::String
|
|
209
|
+
end
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
class Slug
|
|
214
|
+
def normalise(s)
|
|
215
|
+
s.empty? ? nil : s.upcase # warning:
|
|
216
|
+
# def.return-type-mismatch
|
|
217
|
+
# (declared String, inferred
|
|
218
|
+
# String | nil)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The rule is the symmetric counterpart of
|
|
224
|
+
`call.argument-type-mismatch`: argument-side is "the caller
|
|
225
|
+
gave me a wrong type"; return-side is "I gave my caller a
|
|
226
|
+
wrong type."
|
|
227
|
+
|
|
228
|
+
## Block parameters
|
|
229
|
+
|
|
230
|
+
When a method takes a block, Rigor binds the block parameters
|
|
231
|
+
based on the receiver method's signature. Every block-using
|
|
232
|
+
method in the bundled catalog has a per-method rule:
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
[1, 2, 3].each do |n|
|
|
236
|
+
assert_type("1 | 2 | 3", n)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
%w[a b c].each_with_index do |word, idx|
|
|
240
|
+
assert_type("\"a\" | \"b\" | \"c\"", word)
|
|
241
|
+
assert_type("non-negative-int", idx)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
{name: "Alice", age: 30}.each_pair do |key, value|
|
|
245
|
+
assert_type(":age | :name", key)
|
|
246
|
+
assert_type("\"Alice\" | 30", value)
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Per-position binding works for tuples, hash shapes, and
|
|
251
|
+
ranges. When the receiver is widened (`Array[T]` instead of
|
|
252
|
+
`Tuple[…]`), the block parameter is the element type `T`.
|
|
253
|
+
|
|
254
|
+
When the receiving method does not have a per-method rule,
|
|
255
|
+
the block parameter falls back to `Dynamic[top]`. Custom
|
|
256
|
+
block-using methods you write in your project's source are
|
|
257
|
+
seen by the in-source dispatch tier — Rigor walks the body to
|
|
258
|
+
infer the parameter type from `yield` calls — but that
|
|
259
|
+
analysis is more limited than the catalogued built-ins.
|
|
260
|
+
|
|
261
|
+
## Numbered parameters and `it`
|
|
262
|
+
|
|
263
|
+
`_1`, `_2`, ..., and Ruby 3.4's `it` are bound exactly like
|
|
264
|
+
explicit parameters:
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
[1, 2, 3].each { _1.succ }
|
|
268
|
+
# _1: Constant<1> | Constant<2> | Constant<3>
|
|
269
|
+
|
|
270
|
+
[10, 20, 30].each { it.to_s }
|
|
271
|
+
# it: same as the explicit form
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## Block-local declarations (`do |i; x|`)
|
|
275
|
+
|
|
276
|
+
The `;`-prefixed names introduce a fresh block-local
|
|
277
|
+
variable that shadows any outer local of the same name. Rigor
|
|
278
|
+
binds these locals to `Constant<nil>` at block entry — Ruby's
|
|
279
|
+
runtime semantics — and treats writes inside the block as
|
|
280
|
+
local to the block:
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
x = 100
|
|
284
|
+
[1, 2, 3].each do |i; x|
|
|
285
|
+
# x: Constant<nil> at this point — the block-local shadow
|
|
286
|
+
x = i * 2
|
|
287
|
+
# x: Constant<2> | Constant<4> | Constant<6>
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
assert_type("100", x) # outer x untouched
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## Closure escape and captured locals
|
|
294
|
+
|
|
295
|
+
When a block captures an outer local, the block's writes to
|
|
296
|
+
that local flow back into the post-call view of the local. For
|
|
297
|
+
known non-escaping methods (`Array#each`, `tap`, …) the
|
|
298
|
+
write-back is applied; for escaping methods (`Thread.new`,
|
|
299
|
+
`define_method`, …) the analyzer drops captured-local facts
|
|
300
|
+
because the block could fire arbitrarily later.
|
|
301
|
+
|
|
302
|
+
The write-back covers both *rebinding* a local and *mutating a
|
|
303
|
+
collection* the block was building up. A rebound accumulator
|
|
304
|
+
widens to its base type rather than keeping a stale seed, and a
|
|
305
|
+
collection grown inside the block carries the appended element
|
|
306
|
+
types:
|
|
307
|
+
|
|
308
|
+
```ruby
|
|
309
|
+
total = 0
|
|
310
|
+
[1, 2, 3].each { |n| total += n }
|
|
311
|
+
# total: Integer — the rebind is written back (not a stale 0)
|
|
312
|
+
|
|
313
|
+
out = [0]
|
|
314
|
+
[1, 2, 3].each { |x| out << x }
|
|
315
|
+
# out: Array[Integer] — the appended element type joins in,
|
|
316
|
+
# not just the seed's Constant<0>
|
|
317
|
+
|
|
318
|
+
squares = [1, 2, 3].each_with_object([]) { |n, acc| acc << n * n }
|
|
319
|
+
# squares: Array[Integer]
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
The same write-back applies to `while` / `until` loop
|
|
323
|
+
bodies — an accumulator a loop rebinds no longer keeps a stale
|
|
324
|
+
single-pass constant. The analysis runs the body to a small
|
|
325
|
+
fixpoint and widens (it never folds a loop accumulator to a
|
|
326
|
+
value the runtime would exceed). A `for` loop joins a single
|
|
327
|
+
body pass rather than running this fixpoint. For an *escaping* or unknown
|
|
328
|
+
block that mutates a captured collection, the local widens to
|
|
329
|
+
its bare-collection floor instead of an unsoundly-precise seed.
|
|
330
|
+
|
|
331
|
+
This is the conservative call: better to widen too much than
|
|
332
|
+
to claim narrowed-after-escape facts that the runtime might
|
|
333
|
+
violate.
|
|
334
|
+
|
|
335
|
+
## What's next
|
|
336
|
+
|
|
337
|
+
Chapter 6 covers the class side: how Rigor types `self`,
|
|
338
|
+
constant lookup, `attr_*` declarations, and the
|
|
339
|
+
class-vs-instance method distinction.
|