rigortype 0.2.1 → 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.
Files changed (105) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -14
  3. data/docs/handbook/01-getting-started.md +311 -0
  4. data/docs/handbook/02-everyday-types.md +337 -0
  5. data/docs/handbook/03-narrowing.md +359 -0
  6. data/docs/handbook/04-tuples-and-shapes.md +321 -0
  7. data/docs/handbook/05-methods-and-blocks.md +339 -0
  8. data/docs/handbook/06-classes.md +305 -0
  9. data/docs/handbook/07-rbs-and-extended.md +427 -0
  10. data/docs/handbook/08-understanding-errors.md +373 -0
  11. data/docs/handbook/09-plugins.md +241 -0
  12. data/docs/handbook/10-sorbet.md +347 -0
  13. data/docs/handbook/11-sig-gen.md +312 -0
  14. data/docs/handbook/12-lightweight-hkt.md +333 -0
  15. data/docs/handbook/README.md +275 -0
  16. data/docs/handbook/appendix-elixir.md +370 -0
  17. data/docs/handbook/appendix-go.md +399 -0
  18. data/docs/handbook/appendix-java-csharp.md +470 -0
  19. data/docs/handbook/appendix-liskov.md +580 -0
  20. data/docs/handbook/appendix-mypy.md +370 -0
  21. data/docs/handbook/appendix-phpstan.md +338 -0
  22. data/docs/handbook/appendix-protocols-and-structural-typing.md +292 -0
  23. data/docs/handbook/appendix-rust.md +446 -0
  24. data/docs/handbook/appendix-steep.md +336 -0
  25. data/docs/handbook/appendix-type-theory.md +1662 -0
  26. data/docs/handbook/appendix-typeprof.md +416 -0
  27. data/docs/handbook/appendix-typescript.md +332 -0
  28. data/docs/install.md +189 -0
  29. data/docs/llms.txt +72 -0
  30. data/docs/manual/01-installation.md +342 -0
  31. data/docs/manual/02-cli-reference.md +557 -0
  32. data/docs/manual/03-configuration.md +152 -0
  33. data/docs/manual/04-diagnostics.md +206 -0
  34. data/docs/manual/05-inspecting-types.md +109 -0
  35. data/docs/manual/06-baseline.md +104 -0
  36. data/docs/manual/07-plugins.md +92 -0
  37. data/docs/manual/08-skills.md +143 -0
  38. data/docs/manual/09-editor-integration.md +245 -0
  39. data/docs/manual/10-mcp-server.md +532 -0
  40. data/docs/manual/11-ci.md +274 -0
  41. data/docs/manual/12-caching.md +116 -0
  42. data/docs/manual/13-troubleshooting.md +120 -0
  43. data/docs/manual/14-rails-quickstart.md +332 -0
  44. data/docs/manual/15-type-protection-coverage.md +204 -0
  45. data/docs/manual/16-rbs-extended-annotations.md +190 -0
  46. data/docs/manual/17-driving-improvement.md +160 -0
  47. data/docs/manual/README.md +87 -0
  48. data/docs/manual/ci-templates/README.md +58 -0
  49. data/docs/manual/plugins/README.md +86 -0
  50. data/docs/manual/plugins/rigor-actioncable.md +78 -0
  51. data/docs/manual/plugins/rigor-actionmailer.md +74 -0
  52. data/docs/manual/plugins/rigor-actionpack.md +80 -0
  53. data/docs/manual/plugins/rigor-activejob.md +58 -0
  54. data/docs/manual/plugins/rigor-activerecord.md +102 -0
  55. data/docs/manual/plugins/rigor-activestorage.md +74 -0
  56. data/docs/manual/plugins/rigor-activesupport-core-ext.md +86 -0
  57. data/docs/manual/plugins/rigor-devise.md +70 -0
  58. data/docs/manual/plugins/rigor-dry-schema.md +56 -0
  59. data/docs/manual/plugins/rigor-dry-struct.md +60 -0
  60. data/docs/manual/plugins/rigor-dry-types.md +59 -0
  61. data/docs/manual/plugins/rigor-dry-validation.md +62 -0
  62. data/docs/manual/plugins/rigor-factorybot.md +76 -0
  63. data/docs/manual/plugins/rigor-graphql.md +89 -0
  64. data/docs/manual/plugins/rigor-hanami.md +83 -0
  65. data/docs/manual/plugins/rigor-mangrove.md +73 -0
  66. data/docs/manual/plugins/rigor-minitest.md +86 -0
  67. data/docs/manual/plugins/rigor-pundit.md +72 -0
  68. data/docs/manual/plugins/rigor-rails-i18n.md +92 -0
  69. data/docs/manual/plugins/rigor-rails-routes.md +94 -0
  70. data/docs/manual/plugins/rigor-rails.md +44 -0
  71. data/docs/manual/plugins/rigor-rbs-inline.md +83 -0
  72. data/docs/manual/plugins/rigor-rspec-rails.md +72 -0
  73. data/docs/manual/plugins/rigor-rspec.md +86 -0
  74. data/docs/manual/plugins/rigor-shoulda-matchers.md +78 -0
  75. data/docs/manual/plugins/rigor-sidekiq.md +78 -0
  76. data/docs/manual/plugins/rigor-sinatra.md +61 -0
  77. data/docs/manual/plugins/rigor-sorbet.md +63 -0
  78. data/docs/manual/plugins/rigor-statesman.md +75 -0
  79. data/docs/manual/plugins/rigor-typescript-utility-types.md +71 -0
  80. data/exe/rigor +1 -1
  81. data/lib/rigor/analysis/incremental_session.rb +4 -2
  82. data/lib/rigor/analysis/run_stats.rb +13 -1
  83. data/lib/rigor/analysis/runner.rb +54 -12
  84. data/lib/rigor/cli/check_command.rb +1 -1
  85. data/lib/rigor/cli/docs_command.rb +248 -0
  86. data/lib/rigor/cli/skill_command.rb +103 -41
  87. data/lib/rigor/cli/skill_describe.rb +346 -0
  88. data/lib/rigor/cli.rb +25 -3
  89. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +124 -32
  90. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +37 -6
  91. data/lib/rigor/inference/scope_indexer.rb +87 -89
  92. data/lib/rigor/plugin/isolation.rb +5 -5
  93. data/lib/rigor/plugin/loader.rb +4 -2
  94. data/lib/rigor/version.rb +1 -1
  95. data/skills/rigor-ask/SKILL.md +172 -0
  96. data/skills/rigor-doctor/SKILL.md +87 -0
  97. data/skills/rigor-editor-setup/SKILL.md +114 -0
  98. data/skills/rigor-mcp-setup/SKILL.md +117 -0
  99. data/skills/rigor-monkeypatch-resolve/SKILL.md +79 -0
  100. data/skills/rigor-next-steps/SKILL.md +113 -0
  101. data/skills/rigor-plugin-tune/SKILL.md +79 -0
  102. data/skills/rigor-protection-uplift/SKILL.md +133 -0
  103. data/skills/rigor-rbs-setup/SKILL.md +128 -0
  104. data/skills/rigor-upgrade/SKILL.md +79 -0
  105. metadata +90 -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`.