rigortype 0.2.0 → 0.2.2
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 +82 -20
- data/data/core_overlay/numeric.rbs +33 -0
- data/data/core_overlay/pathname.rbs +25 -0
- data/data/core_overlay/string_scanner.rbs +28 -0
- data/data/gem_overlay/activesupport/core_ext.rbs +473 -0
- data/data/vendored_gem_sigs/ast/ast.rbs +130 -0
- data/data/vendored_gem_sigs/bcrypt/bcrypt.rbs +47 -0
- data/data/vendored_gem_sigs/bundler/bundler.rbs +238 -0
- data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +34 -0
- data/data/vendored_gem_sigs/did_you_mean/did_you_mean_extras.rbs +34 -0
- data/data/vendored_gem_sigs/idn-ruby/idn.rbs +54 -0
- data/data/vendored_gem_sigs/mysql2/client.rbs +55 -0
- data/data/vendored_gem_sigs/mysql2/error.rbs +5 -0
- data/data/vendored_gem_sigs/mysql2/result.rbs +31 -0
- data/data/vendored_gem_sigs/mysql2/statement.rbs +5 -0
- data/data/vendored_gem_sigs/nokogiri/nokogiri.rbs +2332 -0
- data/data/vendored_gem_sigs/nokogiri/nokogiri_html5.rbs +47 -0
- data/data/vendored_gem_sigs/pg/pg.rbs +212 -0
- data/data/vendored_gem_sigs/prism/prism_supplement.rbs +44 -0
- data/data/vendored_gem_sigs/redis/errors.rbs +50 -0
- data/data/vendored_gem_sigs/redis/future.rbs +5 -0
- data/data/vendored_gem_sigs/redis/redis.rbs +348 -0
- data/data/vendored_gem_sigs/redis/redis_extras.rbs +130 -0
- data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +226 -0
- 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 +557 -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 +532 -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 +26 -3
- data/lib/rigor/cli/coverage_command.rb +67 -92
- data/lib/rigor/cli/coverage_mutation.rb +149 -0
- data/lib/rigor/cli/docs_command.rb +248 -0
- data/lib/rigor/cli/fused_protection_renderer.rb +67 -0
- data/lib/rigor/cli/fused_protection_report.rb +76 -0
- data/lib/rigor/cli/skill_command.rb +103 -41
- data/lib/rigor/cli/skill_describe.rb +346 -0
- data/lib/rigor/cli.rb +25 -3
- data/lib/rigor/config_audit.rb +152 -0
- data/lib/rigor/configuration.rb +12 -0
- data/lib/rigor/environment/rbs_loader.rb +27 -0
- data/lib/rigor/environment.rb +49 -1
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +140 -38
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +37 -6
- data/lib/rigor/inference/scope_indexer.rb +87 -89
- data/lib/rigor/inference/statement_evaluator.rb +27 -0
- data/lib/rigor/plugin/isolation.rb +5 -5
- data/lib/rigor/plugin/loader.rb +4 -2
- data/lib/rigor/protection/diagnostic_oracle.rb +51 -0
- data/lib/rigor/protection/mutation_scanner.rb +98 -38
- data/lib/rigor/protection/mutator.rb +21 -0
- data/lib/rigor/protection/test_suite_oracle.rb +68 -0
- data/lib/rigor/signature_path_audit.rb +92 -0
- 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 +120 -1
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# Everyday types
|
|
2
|
+
|
|
3
|
+
This is the most important chapter. Once you have a feel for
|
|
4
|
+
the carriers below, the rest of the handbook is rules
|
|
5
|
+
operating on them. This is also the page to come back to as a
|
|
6
|
+
glossary; the table below is the whole carrier zoo at a
|
|
7
|
+
glance.
|
|
8
|
+
|
|
9
|
+
## Why "type" is too coarse a word
|
|
10
|
+
|
|
11
|
+
A vanilla static checker answers "what *class* is this
|
|
12
|
+
object?" Rigor answers a narrower question: "what *subset of
|
|
13
|
+
values* can this expression actually produce?"
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
n = 1 + 2
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
A vanilla checker says: `n: Integer`. Rigor says:
|
|
20
|
+
`n: Constant<3>`. Both are correct; Rigor's is much more
|
|
21
|
+
useful.
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
n = ARGV.size
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
A vanilla checker says: `n: Integer`. Rigor says:
|
|
28
|
+
`n: int<0, max>` (a non-negative integer — `Array#size` cannot
|
|
29
|
+
return a negative count).
|
|
30
|
+
|
|
31
|
+
The reason this matters: most diagnostics Rigor wants to fire
|
|
32
|
+
need the narrower fact. "Integer" is not enough to prove
|
|
33
|
+
`n / 0` always raises; `Constant<0>` is. "Array" is not enough
|
|
34
|
+
to prove `arr.first.upcase` is safe; `non-empty-array[String]`
|
|
35
|
+
is.
|
|
36
|
+
|
|
37
|
+
So: every value at every program point is described by a
|
|
38
|
+
**carrier**. Carriers can be wide (`Integer`, `Dynamic[top]`)
|
|
39
|
+
or narrow (`Constant<3>`, `non-empty-string`). The rest of
|
|
40
|
+
this chapter is the carrier zoo.
|
|
41
|
+
|
|
42
|
+
One note on notation before the zoo: angle brackets hold a
|
|
43
|
+
concrete value or bound — `Constant<3>`, `int<0, max>` —
|
|
44
|
+
while square brackets hold type parameters, exactly as in RBS
|
|
45
|
+
— `Nominal[String]`, `Hash[K, V]`, `Dynamic[top]`.
|
|
46
|
+
|
|
47
|
+
## Seeing carriers yourself — `rigor annotate`
|
|
48
|
+
|
|
49
|
+
Every code example below tags each line with its inferred
|
|
50
|
+
type in a trailing `#=> dump_type:` comment:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
two = 1 + 1 #=> dump_type: Constant<2>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
That is the comment format `rigor annotate FILE` produces:
|
|
57
|
+
it reprints a source file with every line tagged by the
|
|
58
|
+
carrier of the expression that line evaluates to. Run it on
|
|
59
|
+
your own code to watch the carrier zoo appear in the margin.
|
|
60
|
+
(`annotate` prints carriers in their compact display form,
|
|
61
|
+
so it writes `2` where this handbook spells `Constant<2>`
|
|
62
|
+
out in full.)
|
|
63
|
+
|
|
64
|
+
## Nominal types — the familiar starting point
|
|
65
|
+
|
|
66
|
+
The simplest carrier is the one you already know:
|
|
67
|
+
`Nominal[ClassName]`. It says "this is an instance of that
|
|
68
|
+
class" with no additional information.
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
n = ARGV.first #=> dump_type: Nominal[String] | Constant<nil>
|
|
72
|
+
# RBS says `String?` — String | nil
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`Nominal[Integer]`, `Nominal[String]`, `Nominal[Symbol]`,
|
|
76
|
+
`Nominal[Hash[K, V]]` — exactly what you expect. The display
|
|
77
|
+
form drops the `Nominal[]` wrapper for readability:
|
|
78
|
+
`Integer`, `String`, `Hash[String, Integer]`.
|
|
79
|
+
|
|
80
|
+
Rigor reads nominal types from RBS. When you write
|
|
81
|
+
`def foo(s) -> ::String`, the call site's result is
|
|
82
|
+
`Nominal[String]`. When the receiver class has a richer
|
|
83
|
+
catalogue (built-in `String`, `Array`, `Integer`, …), Rigor
|
|
84
|
+
often produces something narrower than nominal — see below.
|
|
85
|
+
|
|
86
|
+
## Constants — single Ruby values
|
|
87
|
+
|
|
88
|
+
`Type::Constant` is Rigor's "I know exactly which value this
|
|
89
|
+
is" carrier. It wraps one Ruby literal:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
n = 42 #=> dump_type: Constant<42>
|
|
93
|
+
s = "hello" #=> dump_type: Constant<"hello">
|
|
94
|
+
sym = :foo #=> dump_type: Constant<:foo>
|
|
95
|
+
t = true #=> dump_type: Constant<true>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Rigor folds arithmetic and string composition aggressively
|
|
99
|
+
when every operand is a Constant:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
two = 1 + 1 #=> dump_type: Constant<2>
|
|
103
|
+
ten = 5 * 2 #=> dump_type: Constant<10>
|
|
104
|
+
hi = "Hello, " + "world" #=> dump_type: Constant<"Hello, world">
|
|
105
|
+
sym = "foo".to_sym #=> dump_type: Constant<:foo>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Folding extends to a long list of "pure" methods on Numeric,
|
|
109
|
+
String, Symbol, Array, and Hash. The list is not in this
|
|
110
|
+
handbook (it would fill several pages); see
|
|
111
|
+
[`docs/types.md`](../types.md) and the per-class catalogues
|
|
112
|
+
under
|
|
113
|
+
[`data/builtins/ruby_core/`](../../data/builtins/ruby_core/).
|
|
114
|
+
|
|
115
|
+
When folding is **not** safe (because a method has side
|
|
116
|
+
effects, depends on the environment, or is not in a
|
|
117
|
+
catalogued built-in class), Rigor declines and you get a
|
|
118
|
+
nominal carrier or `Dynamic[top]`.
|
|
119
|
+
|
|
120
|
+
## Integer ranges — bounded intervals
|
|
121
|
+
|
|
122
|
+
Some integer-valued expressions produce a known range without
|
|
123
|
+
producing a single literal value. Rigor describes those with
|
|
124
|
+
`Type::IntegerRange`, displayed as `int<min, max>`:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
n = ARGV.size #=> dump_type: int<0, max>
|
|
128
|
+
m = n + 1 #=> dump_type: int<1, max>
|
|
129
|
+
double = n * 2 #=> dump_type: int<0, max>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`max` here means "positive infinity" — the upper bound is
|
|
133
|
+
unbounded; `min`, which appears in the table below, is its
|
|
134
|
+
mirror, "negative infinity." Multiplication preserves the
|
|
135
|
+
floor, so `n * 2` stays `int<0, max>`.
|
|
136
|
+
|
|
137
|
+
A handful of common ranges have shorter names:
|
|
138
|
+
|
|
139
|
+
| Spelling | Meaning |
|
|
140
|
+
| --- | --- |
|
|
141
|
+
| `positive-int` | `int<1, max>` |
|
|
142
|
+
| `non-negative-int` | `int<0, max>` |
|
|
143
|
+
| `negative-int` | `int<min, -1>` |
|
|
144
|
+
| `non-positive-int` | `int<min, 0>` |
|
|
145
|
+
|
|
146
|
+
`Array#size`, `Array#length`, `Hash#size`, `String#size`, …
|
|
147
|
+
all carry `non-negative-int`. `Array#count` does too. Adding
|
|
148
|
+
`1` to a `non-negative-int` produces a `positive-int`. Adding
|
|
149
|
+
`-1` produces an unconstrained `Integer` (it could go below
|
|
150
|
+
zero).
|
|
151
|
+
|
|
152
|
+
## Refinements — values restricted by a predicate
|
|
153
|
+
|
|
154
|
+
Some types are not "this nominal class minus / plus a literal
|
|
155
|
+
value" but "this nominal class restricted by a predicate."
|
|
156
|
+
Rigor uses the carrier `Type::Refined` for these, displayed
|
|
157
|
+
with a kebab-case name. The catalogue:
|
|
158
|
+
|
|
159
|
+
| Refinement | Means |
|
|
160
|
+
| --- | --- |
|
|
161
|
+
| `non-empty-string` | `String` whose `#empty?` is provably `false` |
|
|
162
|
+
| `lowercase-string` | `String` equal to its `#downcase` |
|
|
163
|
+
| `uppercase-string` | `String` equal to its `#upcase` |
|
|
164
|
+
| `numeric-string` | `String` parseable as a number |
|
|
165
|
+
| `decimal-int-string` | `String` parseable as a decimal integer |
|
|
166
|
+
| `octal-int-string` | leading `0o` / octal digits |
|
|
167
|
+
| `hex-int-string` | leading `0x` / hex digits |
|
|
168
|
+
| `literal-string` | `String` provably composed from literals |
|
|
169
|
+
| `non-empty-lowercase-string` | both at once |
|
|
170
|
+
| `non-empty-uppercase-string` | both at once |
|
|
171
|
+
| `non-empty-literal-string` | both at once |
|
|
172
|
+
|
|
173
|
+
Most of these carriers come into being one of two ways:
|
|
174
|
+
|
|
175
|
+
1. **Through narrowing** — `if s.empty?` gives `s` the type
|
|
176
|
+
`non-empty-string` in the false branch (see
|
|
177
|
+
[Chapter 3](03-narrowing.md)).
|
|
178
|
+
2. **Through `RBS::Extended` annotations** — a method's RBS
|
|
179
|
+
sig says `String`, but the author knows the runtime
|
|
180
|
+
always returns non-empty, so they tag
|
|
181
|
+
`%a{rigor:v1:return: non-empty-string}` (see
|
|
182
|
+
[Chapter 7](07-rbs-and-extended.md)).
|
|
183
|
+
|
|
184
|
+
Refinements **erase** to their base nominal class for RBS
|
|
185
|
+
interop. A method whose signature says `-> String` keeps
|
|
186
|
+
that contract — Rigor only adds a tighter view inside its
|
|
187
|
+
own analysis.
|
|
188
|
+
|
|
189
|
+
The negation form `~T` denotes the complement: `~lowercase-string`
|
|
190
|
+
is "a String that has at least one non-lowercase character."
|
|
191
|
+
A small number of refinements have a hand-paired complement
|
|
192
|
+
(`lowercase-string` ↔ `non-lowercase-string`) which Rigor
|
|
193
|
+
prefers when it can; the rest fall back to a generic
|
|
194
|
+
`Difference` form.
|
|
195
|
+
|
|
196
|
+
## Difference — a base minus a single value
|
|
197
|
+
|
|
198
|
+
`non-empty-string` could equivalently be spelled
|
|
199
|
+
`String - ""`. Rigor uses `Type::Difference` for this kind of
|
|
200
|
+
carrier:
|
|
201
|
+
|
|
202
|
+
| Carrier | Equivalent |
|
|
203
|
+
| --- | --- |
|
|
204
|
+
| `non-empty-string` | `String - ""` |
|
|
205
|
+
| `non-zero-int` | `Integer - 0` |
|
|
206
|
+
| `non-empty-array[T]` | `Array[T] - []` |
|
|
207
|
+
| `non-empty-hash[K, V]` | `Hash[K, V] - {}` |
|
|
208
|
+
|
|
209
|
+
You will see them most often in narrowing:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
n = some_integer_call
|
|
213
|
+
if n.zero?
|
|
214
|
+
n #=> dump_type: Constant<0>
|
|
215
|
+
else
|
|
216
|
+
n #=> dump_type: non-zero-int
|
|
217
|
+
end
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## `Dynamic[top]` — the gradual carrier
|
|
221
|
+
|
|
222
|
+
Sometimes Rigor cannot prove anything tighter than "this
|
|
223
|
+
could be any Ruby value" — a bare parameter, for instance,
|
|
224
|
+
carries no calling-side information. That is `Dynamic[top]`,
|
|
225
|
+
often shortened to `untyped` for the RBS-erased view.
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
def foo(x)
|
|
229
|
+
x.bar #=> dump_type: Dynamic[top]
|
|
230
|
+
end
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`Dynamic[T]` (with a non-Top inner) is the more specific
|
|
234
|
+
gradual form: "we do not have a static contract for this
|
|
235
|
+
value, but the static facet behaves like `T`." It pops up
|
|
236
|
+
when an RBS-declared `untyped` boundary meets a class Rigor
|
|
237
|
+
already knows something about.
|
|
238
|
+
|
|
239
|
+
A diagnostic NEVER fires on a `Dynamic[top]` receiver. That is
|
|
240
|
+
the no-false-positives stance — Rigor stays silent rather
|
|
241
|
+
than reporting on values it cannot characterise.
|
|
242
|
+
|
|
243
|
+
## Tuples and hash shapes — heterogeneous structures
|
|
244
|
+
|
|
245
|
+
`[1, "two", :three]` is more specific than "an Array of mixed
|
|
246
|
+
elements." Rigor describes it with `Type::Tuple`:
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
arr = [1, "two", :three]
|
|
250
|
+
#=> dump_type: Tuple[Constant<1>, Constant<"two">, Constant<:three>]
|
|
251
|
+
|
|
252
|
+
first, second, third = arr
|
|
253
|
+
first #=> dump_type: Constant<1>
|
|
254
|
+
second #=> dump_type: Constant<"two">
|
|
255
|
+
third #=> dump_type: Constant<:three>
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Same for hashes with literal keys:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
h = { name: "Alice", age: 30 }
|
|
262
|
+
#=> dump_type: HashShape{name: Constant<"Alice">, age: Constant<30>}
|
|
263
|
+
|
|
264
|
+
h[:name] #=> dump_type: Constant<"Alice">
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Tuples and hash shapes erase to `Array[…]` and `Hash[K, V]`
|
|
268
|
+
when crossing an RBS boundary. Inside Rigor they carry the
|
|
269
|
+
full per-position / per-key type information so destructuring
|
|
270
|
+
and slot access stay precise.
|
|
271
|
+
|
|
272
|
+
Chapter 4 covers tuples and hash shapes in depth.
|
|
273
|
+
|
|
274
|
+
## Unions — "one of these"
|
|
275
|
+
|
|
276
|
+
When a value can be one of finitely many types, Rigor uses
|
|
277
|
+
`Type::Union`:
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
label = case n
|
|
281
|
+
when 0 then :zero
|
|
282
|
+
when 1..9 then :small
|
|
283
|
+
else :large
|
|
284
|
+
end
|
|
285
|
+
#=> dump_type: Constant<:zero> | Constant<:small> | Constant<:large>
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
A union of constants is the closest Ruby gets to a sum type or
|
|
289
|
+
discriminated union. Rigor takes them seriously: switching on
|
|
290
|
+
a literal-union value with `case` produces a precise narrowing
|
|
291
|
+
(see [Chapter 3](03-narrowing.md)).
|
|
292
|
+
|
|
293
|
+
There are limits — Rigor does not extend a union past a
|
|
294
|
+
configurable size budget. Beyond that, it widens to the union
|
|
295
|
+
of the members' nominal bases. This keeps the analyzer fast
|
|
296
|
+
and predictable on degenerate input.
|
|
297
|
+
|
|
298
|
+
## A worked example
|
|
299
|
+
|
|
300
|
+
Putting it together:
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
def classify(n)
|
|
304
|
+
if n.zero?
|
|
305
|
+
:zero
|
|
306
|
+
elsif n.positive?
|
|
307
|
+
:positive
|
|
308
|
+
else
|
|
309
|
+
:negative
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
result = classify(some_integer_input)
|
|
314
|
+
#=> dump_type: Constant<:zero> | Constant<:positive> | Constant<:negative>
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
A vanilla type-checker would call `result: Symbol`. Rigor
|
|
318
|
+
narrows to the exact 3-element union. If you later write
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
case result
|
|
322
|
+
when :positive then "+"
|
|
323
|
+
when :negative then "-"
|
|
324
|
+
when :zero then "0"
|
|
325
|
+
end
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Rigor proves the `case` is exhaustive — every union member
|
|
329
|
+
matches some `when` — and the result is
|
|
330
|
+
`Constant<"+"> | Constant<"-"> | Constant<"0">`.
|
|
331
|
+
|
|
332
|
+
## What's next
|
|
333
|
+
|
|
334
|
+
Chapter 3 (narrowing) is the engine that takes these carriers
|
|
335
|
+
and changes them as control flow passes — `if` / `case` /
|
|
336
|
+
`is_a?` / `nil?`. That is where the value-lattice carriers
|
|
337
|
+
above start paying for themselves.
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# Narrowing
|
|
2
|
+
|
|
3
|
+
A carrier describes a value at one program point. **Narrowing**
|
|
4
|
+
describes how the carrier changes when control flow passes
|
|
5
|
+
through a predicate. This chapter walks through every form of
|
|
6
|
+
narrowing Rigor recognises today.
|
|
7
|
+
|
|
8
|
+
The mental model: each predicate produces two scopes — one
|
|
9
|
+
for the truthy edge and one for the falsey edge. Inside each
|
|
10
|
+
edge, the variable's carrier is sharpened to whatever the
|
|
11
|
+
predicate proved. If the predicate is unrecognised, both
|
|
12
|
+
edges share the entry scope unchanged.
|
|
13
|
+
|
|
14
|
+
## Truthiness narrowing
|
|
15
|
+
|
|
16
|
+
The simplest form. `if x` separates "x is truthy" from "x is
|
|
17
|
+
`false` or `nil`":
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
def shout(name)
|
|
21
|
+
if name
|
|
22
|
+
# name: String — `false | nil` removed by truthy edge
|
|
23
|
+
name.upcase
|
|
24
|
+
else
|
|
25
|
+
# name: Constant<false> | Constant<nil>
|
|
26
|
+
"(no name)"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This is what makes the ubiquitous `if value` idiom useful at
|
|
32
|
+
lint time: inside the `if` body, Rigor knows `value` is
|
|
33
|
+
non-nil.
|
|
34
|
+
|
|
35
|
+
## `nil?` and the inverse
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
def length(s)
|
|
39
|
+
return 0 if s.nil?
|
|
40
|
+
# s: Nominal[String] (the nil component of String? is gone)
|
|
41
|
+
s.length
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`s.nil?` narrows the truthy edge to `Constant<nil>` and the
|
|
46
|
+
falsey edge to "everything else" — typically the original
|
|
47
|
+
type with `nil` removed.
|
|
48
|
+
|
|
49
|
+
## `is_a?`, `kind_of?`, `instance_of?`
|
|
50
|
+
|
|
51
|
+
These three all narrow on the class hierarchy:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
def kind(x)
|
|
55
|
+
if x.is_a?(Integer)
|
|
56
|
+
# x: Integer
|
|
57
|
+
x + 1
|
|
58
|
+
elsif x.is_a?(String)
|
|
59
|
+
# x: String
|
|
60
|
+
x.length
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Subclass relationships are honoured: `is_a?(Numeric)` accepts
|
|
66
|
+
`Integer` and `Float` and narrows accordingly. `instance_of?`
|
|
67
|
+
is stricter — only the exact class — and Rigor narrows
|
|
68
|
+
correspondingly.
|
|
69
|
+
|
|
70
|
+
The falsey edge subtracts the matched class:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
x = some_call_that_returns_integer_or_string
|
|
74
|
+
unless x.is_a?(Integer)
|
|
75
|
+
# x: String — Integer subtracted
|
|
76
|
+
x.upcase
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Equality with literal values
|
|
81
|
+
|
|
82
|
+
Rigor narrows `==` and `!=` against trusted literal values:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
state = some_call_returning_a_symbol
|
|
86
|
+
if state == :ready
|
|
87
|
+
# state: Constant<:ready>
|
|
88
|
+
send_request
|
|
89
|
+
elsif state == :pending
|
|
90
|
+
# state: Constant<:pending>
|
|
91
|
+
retry_in(5)
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
This is most useful when `state` is itself a union of
|
|
96
|
+
constants (`Constant<:ready> | Constant<:pending> |
|
|
97
|
+
Constant<:failed>`). Each branch peels one member off, and
|
|
98
|
+
Rigor can prove the final `else` is one of the remaining
|
|
99
|
+
constants — not "any Symbol."
|
|
100
|
+
|
|
101
|
+
## `case` / `when`
|
|
102
|
+
|
|
103
|
+
`case x; when …` is narrowing-syntax sugar over equality and
|
|
104
|
+
class checks. Each `when` branch sees `x` narrowed to the
|
|
105
|
+
matched member:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
case n
|
|
109
|
+
when 0 then :zero # Constant<0>
|
|
110
|
+
when 1..9 then :small # int<1, 9>
|
|
111
|
+
when 10 then :ten # Constant<10>
|
|
112
|
+
else :large # everything else
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The result type unions the per-branch results. When the input
|
|
117
|
+
is a finite literal union, Rigor proves the `else` branch is
|
|
118
|
+
unreachable when every member is matched.
|
|
119
|
+
|
|
120
|
+
The same narrowing also works in reverse: when a `when <Class>`
|
|
121
|
+
clause is disjoint from the subject's type, or an earlier
|
|
122
|
+
clause already covered it, the clause is dead — Rigor emits
|
|
123
|
+
[`flow.unreachable-clause`](08-understanding-errors.md) so you
|
|
124
|
+
can delete it. (It ships at `:info` under the default profile.)
|
|
125
|
+
|
|
126
|
+
`case x; in pattern` (one-line pattern matching) narrows the
|
|
127
|
+
same way for the patterns Rigor understands — class checks,
|
|
128
|
+
literal equality, array / hash structural patterns. The
|
|
129
|
+
clause-reachability check extends to bare-class `in` patterns
|
|
130
|
+
(`in String` / `in MyClass => x`) too.
|
|
131
|
+
|
|
132
|
+
## Boolean composition
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
def safe_size(s)
|
|
136
|
+
if s && !s.empty?
|
|
137
|
+
# s: non-empty-string
|
|
138
|
+
s.size
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`&&` chains left-to-right narrowing: the right-hand operand
|
|
144
|
+
is evaluated under the truthy edge of the left. `||` chains
|
|
145
|
+
the falsey edge. `!` swaps the two edges.
|
|
146
|
+
|
|
147
|
+
This composes with everything else:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
if x.is_a?(Integer) && x > 0
|
|
151
|
+
# x: positive-int
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The `is_a?` narrowed `x` to `Integer`, then the integer
|
|
156
|
+
comparison narrowed it further to `int<1, max>`.
|
|
157
|
+
|
|
158
|
+
## Integer comparisons
|
|
159
|
+
|
|
160
|
+
`<`, `<=`, `>`, `>=`, plus `Integer#zero?` / `#positive?` /
|
|
161
|
+
`#negative?` / `#nonzero?` / `Comparable#between?`, all narrow
|
|
162
|
+
integer ranges:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
def safe_index(arr, n)
|
|
166
|
+
return :empty if arr.empty?
|
|
167
|
+
return :out_of_range if n < 0 || n >= arr.size
|
|
168
|
+
# n: int<0, arr.size - 1> (in practice: int<0, max>
|
|
169
|
+
# tightened against `n >= arr.size`)
|
|
170
|
+
arr.fetch(n)
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Range comparisons compose with literals:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
n = some_input
|
|
178
|
+
if n.between?(1, 9)
|
|
179
|
+
# n: int<1, 9>
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Predicate methods on refinements
|
|
184
|
+
|
|
185
|
+
Rigor recognises a small set of "type-carrier predicate
|
|
186
|
+
methods" — methods whose return type is `bool` and whose
|
|
187
|
+
truthy / falsey edges narrow the receiver:
|
|
188
|
+
|
|
189
|
+
| Method | Narrows the receiver to |
|
|
190
|
+
| --- | --- |
|
|
191
|
+
| `String#empty?` | `Constant<"">` (truthy) / `non-empty-string` (falsey) |
|
|
192
|
+
| `Array#empty?` | `Constant<[]>` (truthy) / `non-empty-array[T]` (falsey) |
|
|
193
|
+
| `Hash#empty?` | `Constant<{}>` (truthy) / `non-empty-hash[K,V]` (falsey) |
|
|
194
|
+
| `Integer#zero?` | `Constant<0>` (truthy) / `non-zero-int` (falsey) |
|
|
195
|
+
| `Integer#positive?` | `positive-int` (truthy) / `non-positive-int` (falsey) |
|
|
196
|
+
| `Integer#negative?` | `negative-int` (truthy) / `non-negative-int` (falsey) |
|
|
197
|
+
|
|
198
|
+
Compose these as you would expect:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
def first_word(s)
|
|
202
|
+
return "" if s.empty?
|
|
203
|
+
# s: non-empty-string
|
|
204
|
+
s.split.first # at runtime always returns String,
|
|
205
|
+
# never nil — and Rigor knows it
|
|
206
|
+
end
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Hash key-presence narrowing
|
|
210
|
+
|
|
211
|
+
When you guard on `h.key?(:foo)` (or `has_key?`) and `h` is a
|
|
212
|
+
`HashShape` with `:foo` as an *optional* key, the truthy edge
|
|
213
|
+
promotes that key to *required* — so reading it no longer
|
|
214
|
+
includes `nil` for "key absent":
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
def port_of(config)
|
|
218
|
+
# config: HashShape{ host: String, ?port: Integer } (port optional)
|
|
219
|
+
if config.key?(:port)
|
|
220
|
+
# config[:port]: Integer — the optional key is now required
|
|
221
|
+
config[:port] + 1
|
|
222
|
+
else
|
|
223
|
+
80
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
This narrows only concrete `HashShape` carriers, never a
|
|
229
|
+
`Dynamic[T]` hash, and only the truthy edge — a false `key?`
|
|
230
|
+
does not prove much about the other keys.
|
|
231
|
+
|
|
232
|
+
## Array non-empty narrowing
|
|
233
|
+
|
|
234
|
+
A bare `arr.empty?` / `arr.any?` / `arr.none?` guard (no block,
|
|
235
|
+
no arguments) refines an `Array[T]` to `non-empty-array[T]` on
|
|
236
|
+
the edge that proves there is at least one element:
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
def first_or_default(arr)
|
|
240
|
+
# arr: Array[String]
|
|
241
|
+
if arr.any?
|
|
242
|
+
# arr: non-empty-array[String]
|
|
243
|
+
arr.size # positive-int — proven at least 1
|
|
244
|
+
arr.first # String — Rigor already returns T here
|
|
245
|
+
else
|
|
246
|
+
"(empty)"
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`empty?` narrows the *false* edge (the array is NOT empty),
|
|
252
|
+
`any?` / `none?` narrow as you would expect. As with the hash
|
|
253
|
+
form, this fires only on a concrete `Array` carrier — never on
|
|
254
|
+
`Dynamic[T]`, and never on the string / range `empty?` /
|
|
255
|
+
`any?` that look the same syntactically.
|
|
256
|
+
|
|
257
|
+
## Named-capture regex narrowing
|
|
258
|
+
|
|
259
|
+
When a regex with named captures matches in the predicate
|
|
260
|
+
position of `if` / `unless`, the captured locals are bound to
|
|
261
|
+
`String | nil` after the match, and narrowed to `String` in
|
|
262
|
+
the truthy branch:
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
def parse_date(s)
|
|
266
|
+
if /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/ =~ s
|
|
267
|
+
# year, month, day: String (narrowed from String | nil)
|
|
268
|
+
"#{year}/#{month}/#{day}"
|
|
269
|
+
else
|
|
270
|
+
"no match"
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
(Narrowing the truthy edge further to specific refinement
|
|
276
|
+
carriers — so `\d{4}` would produce `decimal-int-string` — is a
|
|
277
|
+
demand-driven follow-up on the regex-pattern → refinement-name
|
|
278
|
+
recogniser track; see [`docs/ROADMAP.md`](../ROADMAP.md).)
|
|
279
|
+
|
|
280
|
+
## Negation and `unless`
|
|
281
|
+
|
|
282
|
+
Both are mechanical mirrors of their non-negated forms.
|
|
283
|
+
`unless x` is `if !x` for narrowing purposes; `x != y` is
|
|
284
|
+
`!(x == y)`. Rigor swaps the two edges.
|
|
285
|
+
|
|
286
|
+
## Local rebinding flips the narrowing
|
|
287
|
+
|
|
288
|
+
A narrowing fact is **scope-local**. The moment you reassign
|
|
289
|
+
the variable, the fact resets:
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
def example(s)
|
|
293
|
+
return if s.nil?
|
|
294
|
+
# s: String
|
|
295
|
+
|
|
296
|
+
s = some_other_call # s rebound — narrowing dropped
|
|
297
|
+
s.upcase # s: String? again, depending on
|
|
298
|
+
# the call's return type
|
|
299
|
+
end
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
This is why the engine's narrowing facts are bound to a
|
|
303
|
+
specific scope, not a specific variable name. Rebinding is
|
|
304
|
+
detected; mutation through method calls is not (Rigor does
|
|
305
|
+
not chase mutation).
|
|
306
|
+
|
|
307
|
+
## What's not narrowed (yet)
|
|
308
|
+
|
|
309
|
+
A few forms you might expect that Rigor does **not** narrow
|
|
310
|
+
today:
|
|
311
|
+
|
|
312
|
+
- `respond_to?(:method_name)` records only a non-nil
|
|
313
|
+
narrowing — a truthy check with a statically-known symbol
|
|
314
|
+
removes `nil` from the receiver (since `nil` does not
|
|
315
|
+
respond to most methods) — but it does **not** yet expose a
|
|
316
|
+
structural "this object responds to that method" capability
|
|
317
|
+
fact you could dispatch against.
|
|
318
|
+
- `frozen?` and other mutation guards — Rigor does not track
|
|
319
|
+
mutability as a narrowing fact yet.
|
|
320
|
+
- Open-ended class-comparison via `===` against arbitrary
|
|
321
|
+
user-defined `case_eq` — only Class / Module / Range /
|
|
322
|
+
Regexp are recognised.
|
|
323
|
+
- Method-chain receivers in `self`-targeted directives
|
|
324
|
+
(`get_user.admin?`) — there is no scope binding to narrow
|
|
325
|
+
against. Local, instance-variable, explicit-`self`, and
|
|
326
|
+
implicit-self receivers are all supported.
|
|
327
|
+
|
|
328
|
+
When narrowing is not recognised, both edges share the entry
|
|
329
|
+
scope unchanged — Rigor stays conservative rather than
|
|
330
|
+
making a wrong call.
|
|
331
|
+
|
|
332
|
+
## Reading a narrowing trace
|
|
333
|
+
|
|
334
|
+
When you want to see what Rigor narrowed at a point:
|
|
335
|
+
|
|
336
|
+
```ruby
|
|
337
|
+
def foo(x)
|
|
338
|
+
if x.is_a?(Integer)
|
|
339
|
+
dump_type(x) # emits an info diagnostic at this line
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
`dump_type(...)` is the introspection helper. It is a no-op
|
|
345
|
+
at runtime (lives in the `Kernel` extension Rigor's test
|
|
346
|
+
harness uses) and emits a `dump.type` diagnostic naming the
|
|
347
|
+
inferred type. Use it during debugging to confirm a narrowing
|
|
348
|
+
fired.
|
|
349
|
+
|
|
350
|
+
`assert_type("expected-string", value)` is the stricter
|
|
351
|
+
sibling: it emits a diagnostic when the inferred type does
|
|
352
|
+
NOT match the string. It is what the handbook examples use to
|
|
353
|
+
pin behaviour.
|
|
354
|
+
|
|
355
|
+
## What's next
|
|
356
|
+
|
|
357
|
+
Chapter 4 covers the structural carriers — `Tuple` and
|
|
358
|
+
`HashShape` — which behave a lot like a per-element
|
|
359
|
+
narrowing of `Array` and `Hash`.
|