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,580 @@
|
|
|
1
|
+
# Appendix — The Liskov Substitution Principle
|
|
2
|
+
|
|
3
|
+
A bridge between the **Liskov Substitution Principle (LSP)** — the
|
|
4
|
+
classical statement of what it *means* for one type to substitute
|
|
5
|
+
for another — and Rigor's design. The thesis of this page is small,
|
|
6
|
+
and once seen it is hard to un-see:
|
|
7
|
+
|
|
8
|
+
> Rigor's robustness principle (Postel's law for types, strict on
|
|
9
|
+
> returns, lenient on parameters) **is** the LSP signature rule,
|
|
10
|
+
> reached from the opposite direction. LSP derives the
|
|
11
|
+
> wider-parameters / narrower-returns asymmetry *top-down* from
|
|
12
|
+
> "substitutability must preserve correctness." Rigor derives the
|
|
13
|
+
> identical asymmetry *bottom-up* from "minimise call-site friction
|
|
14
|
+
> and maximise downstream precision so Ruby programmers will
|
|
15
|
+
> actually run a type checker." Two motivations, one rule.
|
|
16
|
+
|
|
17
|
+
That convergence matters because it answers a worry a Ruby
|
|
18
|
+
programmer reasonably has about any type checker: *will this thing
|
|
19
|
+
fight my idioms?* It will not. The discipline Rigor applies is the
|
|
20
|
+
same discipline the Ruby community already teaches under the "L" of
|
|
21
|
+
SOLID, and the same one a careful duck-typer follows by instinct.
|
|
22
|
+
|
|
23
|
+
> **A note on the acronym.** Everywhere else in this repository
|
|
24
|
+
> "LSP" means the **Language Server Protocol**
|
|
25
|
+
> ([ADR-19](../adr/19-language-server-packaging.md), `rigor lsp`).
|
|
26
|
+
> On *this* page, and only this page, "LSP" means the **Liskov
|
|
27
|
+
> Substitution Principle**. The collision is unfortunate and
|
|
28
|
+
> entirely conventional; the rest of the corpus keeps the
|
|
29
|
+
> language-server meaning.
|
|
30
|
+
|
|
31
|
+
This page is descriptive, not normative. When the language here
|
|
32
|
+
disagrees with the [type
|
|
33
|
+
specification](../type-specification/README.md), the spec binds.
|
|
34
|
+
|
|
35
|
+
## Five-second pitch
|
|
36
|
+
|
|
37
|
+
| LSP obligation | Ruby idiom that already honours it | Rigor surface |
|
|
38
|
+
| --- | --- | --- |
|
|
39
|
+
| A subtype may be substituted wherever the supertype is expected | Duck typing — "if it responds to the messages I send, I can use it" | Nominal-first typing + structural facets + capability roles |
|
|
40
|
+
| **Signature rule** — parameters contravariant, returns covariant | "Accept the widest thing you can use; return the most specific thing you can promise" | The **robustness principle** ([ADR-5](../adr/5-robustness-principle.md)) — lenient parameters (clause 2), strict returns (clause 1) |
|
|
41
|
+
| **Preconditions may not be strengthened** in a subtype | An override should not reject inputs the parent accepted | Clause 2: parameters widened to the largest correctness-preserving carrier |
|
|
42
|
+
| **Postconditions may not be weakened** in a subtype | An override should not return something less specific than the parent promised | Clause 1: returns tightened to the smallest correctness-preserving carrier; `def.return-type-mismatch` |
|
|
43
|
+
| **Invariants must be preserved**; **history constraint** | Don't add state transitions that break the parent's invariants; respect `freeze` | Mutation-effect model + fact stability + `frozen?` narrowing (partial; see § "Invariants") |
|
|
44
|
+
| Exception compatibility — no surprising new exceptions | Rescue what the contract documents | Internal exception/non-local-exit effect (not checked across overrides; see § "What Rigor does NOT check") |
|
|
45
|
+
|
|
46
|
+
## What LSP actually says
|
|
47
|
+
|
|
48
|
+
Barbara Liskov's 1987 OOPSLA keynote (*Data Abstraction and
|
|
49
|
+
Hierarchy*) stated the intuition:
|
|
50
|
+
|
|
51
|
+
> What is wanted here is something like the following substitution
|
|
52
|
+
> property: if for each object `o1` of type `S` there is an object
|
|
53
|
+
> `o2` of type `T` such that for all programs `P` defined in terms
|
|
54
|
+
> of `T`, the behavior of `P` is unchanged when `o1` is substituted
|
|
55
|
+
> for `o2`, then `S` is a subtype of `T`.
|
|
56
|
+
|
|
57
|
+
**Liskov & Wing 1994** (*A Behavioral Notion of Subtyping*, ACM
|
|
58
|
+
TOPLAS) made it precise. Behavioral subtyping `S <: T` requires two
|
|
59
|
+
groups of conditions:
|
|
60
|
+
|
|
61
|
+
### The signature rule
|
|
62
|
+
|
|
63
|
+
For every method the subtype shares with the supertype:
|
|
64
|
+
|
|
65
|
+
- **Contravariant parameters** — the subtype's method must accept
|
|
66
|
+
*at least* every argument type the supertype's accepted (it may
|
|
67
|
+
accept more).
|
|
68
|
+
- **Covariant return** — the subtype's method must return *at most*
|
|
69
|
+
the supertype's return type (it may return something more
|
|
70
|
+
specific).
|
|
71
|
+
- **Exception rule** — the subtype's method may raise *fewer*
|
|
72
|
+
exception types than the supertype's, never new ones.
|
|
73
|
+
|
|
74
|
+
### The behavioral (methods) rule
|
|
75
|
+
|
|
76
|
+
Beyond signatures, the *behaviour* must be compatible — this is the
|
|
77
|
+
part most signature-only type checkers do not encode:
|
|
78
|
+
|
|
79
|
+
- **Preconditions may not be strengthened.** A subtype's method may
|
|
80
|
+
require *no more* of its caller than the supertype's did.
|
|
81
|
+
- **Postconditions may not be weakened.** A subtype's method must
|
|
82
|
+
guarantee *at least* what the supertype's did.
|
|
83
|
+
- **Invariants must be preserved.** Properties true of every
|
|
84
|
+
supertype instance stay true of every subtype instance.
|
|
85
|
+
- **History constraint.** The subtype may not permit state changes
|
|
86
|
+
that the supertype's specification forbids over an object's
|
|
87
|
+
lifetime (the rule that rules out, e.g., a mutable `Point`
|
|
88
|
+
subtyping an immutable one).
|
|
89
|
+
|
|
90
|
+
The behavioral rule is the **Design-by-Contract** inheritance
|
|
91
|
+
discipline (Bertrand Meyer, Eiffel) stated as a subtyping law:
|
|
92
|
+
`require` clauses weaken down the hierarchy, `ensure` clauses
|
|
93
|
+
strengthen, `invariant` clauses accumulate.
|
|
94
|
+
|
|
95
|
+
## LSP is about behaviour, not static types
|
|
96
|
+
|
|
97
|
+
A claim surfaces occasionally: *"Ruby is dynamically typed, so LSP
|
|
98
|
+
is a static-typing rule that does not strictly apply."* This gets
|
|
99
|
+
the principle backwards.
|
|
100
|
+
|
|
101
|
+
LSP is not a rule *about* type checkers. It is a rule about
|
|
102
|
+
**observable behaviour under substitution** — Liskov's own framing
|
|
103
|
+
quantifies over "all programs `P` defined in terms of `T`" and asks
|
|
104
|
+
that their *behaviour* be unchanged when an `S` is substituted. The
|
|
105
|
+
1994 paper's title is deliberate: *A **Behavioral** Notion of
|
|
106
|
+
Subtyping*. The signature rule is only the type-system-shaped half;
|
|
107
|
+
the load-bearing half is the behavioral rule (preconditions,
|
|
108
|
+
postconditions, invariants, history), and those are statements about
|
|
109
|
+
what code *does at runtime*, not about what a compiler accepts.
|
|
110
|
+
|
|
111
|
+
That behavioral focus is exactly what makes LSP **more** applicable
|
|
112
|
+
to a language like Ruby, not less:
|
|
113
|
+
|
|
114
|
+
- In a nominally-typed language a compiler enforces the signature
|
|
115
|
+
half for you, so LSP feels like "a thing the type checker already
|
|
116
|
+
did." The interesting, un-automated part is the behavioral half.
|
|
117
|
+
- In Ruby there is no compiler enforcing *either* half — so the
|
|
118
|
+
*entire* principle, signature and behaviour both, is a discipline
|
|
119
|
+
the programmer carries. "Subtype" in Ruby is defined by
|
|
120
|
+
substitutability of *messages and behaviour* (duck typing), which
|
|
121
|
+
is precisely the relation LSP is stated over. A language that is
|
|
122
|
+
hard to formalise is the case where a behavioural substitutability
|
|
123
|
+
discipline earns the most: it is the only safety net available
|
|
124
|
+
when a static one is not.
|
|
125
|
+
|
|
126
|
+
So "Ruby is not statically typed" is an argument *for* taking LSP
|
|
127
|
+
seriously, not against. LSP is the tool that lets you reason about
|
|
128
|
+
the *behavioural* safety of substitution in a language whose shape a
|
|
129
|
+
classical type system cannot fully capture. Every working
|
|
130
|
+
duck-typed Ruby program already depends on something LSP-shaped
|
|
131
|
+
holding — the caller assumes the object it received behaves
|
|
132
|
+
compatibly with the contract it was written against.
|
|
133
|
+
|
|
134
|
+
### Rigor's relationship to this
|
|
135
|
+
|
|
136
|
+
Rigor does **not** provide a direct *static guarantee* that a
|
|
137
|
+
program obeys LSP. It does not prove behavioral subtyping, does not
|
|
138
|
+
check cross-hierarchy override compatibility, and does not verify
|
|
139
|
+
pre/postcondition contracts (§ "What Rigor does NOT check"). On that
|
|
140
|
+
narrow reading, "Rigor is not an LSP checker" is true.
|
|
141
|
+
|
|
142
|
+
But the *ideas* have substantial common ground, and that is the
|
|
143
|
+
point of this whole appendix. Rigor's design is steered by the same
|
|
144
|
+
behavioural-substitutability instinct LSP formalises:
|
|
145
|
+
|
|
146
|
+
- The robustness principle infers the LSP-correct signature shape
|
|
147
|
+
(wide parameters, narrow returns) by default (§ next).
|
|
148
|
+
- Capability roles model substitutability the way duck typing does —
|
|
149
|
+
by behaviour (messages answered), not by nominal identity.
|
|
150
|
+
- The mutation-effect and fact-stability model refuses to keep
|
|
151
|
+
trusting a property a state change could have broken — the
|
|
152
|
+
history-constraint instinct, applied locally.
|
|
153
|
+
|
|
154
|
+
Rigor is therefore best read as *tooling that shares LSP's
|
|
155
|
+
behavioural worldview and mechanises the parts of it that are
|
|
156
|
+
statically provable in Ruby*, while leaving the rest as the
|
|
157
|
+
discipline it has always been. The principle and the tool are
|
|
158
|
+
aligned in spirit even where the tool stops short of a proof.
|
|
159
|
+
|
|
160
|
+
## The signature rule is Rigor's robustness principle
|
|
161
|
+
|
|
162
|
+
This is the heart of the page. Lay the two rules side by side:
|
|
163
|
+
|
|
164
|
+
| Position | LSP signature rule | Rigor robustness principle |
|
|
165
|
+
| --- | --- | --- |
|
|
166
|
+
| Parameters | **Contravariant** — accept at least as much as the supertype | **Lenient** (clause 2) — widest correctness-preserving carrier |
|
|
167
|
+
| Returns | **Covariant** — return at most what the supertype promised | **Strict** (clause 1) — smallest correctness-preserving carrier |
|
|
168
|
+
|
|
169
|
+
They are the same asymmetry. What differs is *why* each system
|
|
170
|
+
reaches for it.
|
|
171
|
+
|
|
172
|
+
**LSP's derivation is top-down.** Start from "an `S` must be usable
|
|
173
|
+
anywhere a `T` is expected." A caller written against `T` may pass
|
|
174
|
+
any `T`-typed argument, so `S`'s method must accept all of them —
|
|
175
|
+
parameters can only widen. A caller written against `T` will use
|
|
176
|
+
the result as a `T`, so `S`'s method must return something every
|
|
177
|
+
`T`-context can consume — returns can only narrow. The asymmetry
|
|
178
|
+
*falls out* of substitutability; it is not chosen.
|
|
179
|
+
|
|
180
|
+
**Rigor's derivation is bottom-up.** ADR-5 starts from a Ruby-
|
|
181
|
+
adoption problem, not a substitutability proof
|
|
182
|
+
([`robustness-principle.md`](../type-specification/robustness-principle.md)):
|
|
183
|
+
|
|
184
|
+
- An over-strict **parameter** type forces callers to paste
|
|
185
|
+
defensive coercions (`x.to_s`, `x || ""`, `Array(x)`) at every
|
|
186
|
+
call site. The workarounds become load-bearing and hide intent.
|
|
187
|
+
So Rigor widens parameters to "anything the body can actually
|
|
188
|
+
use" — capability roles, structural interfaces, supertypes.
|
|
189
|
+
- An over-wide **return** type discards precision every downstream
|
|
190
|
+
consumer needs. `Array#size` typed `Integer` rather than
|
|
191
|
+
`non-negative-int` collapses every later `if size > 0`. So Rigor
|
|
192
|
+
tightens returns to "the most specific thing the body provably
|
|
193
|
+
produces."
|
|
194
|
+
|
|
195
|
+
Two completely different starting points (a 1994 substitutability
|
|
196
|
+
theorem and a 2020s "please don't make Ruby programmers write
|
|
197
|
+
`.to_s` everywhere" ergonomics concern) land on the
|
|
198
|
+
*identical* rule. That convergence is the reassurance: Rigor's
|
|
199
|
+
pragmatic, Ruby-first defaults are not a departure from classical
|
|
200
|
+
OO type discipline, they re-derive it.
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
# Clause 2 / contravariant parameters: the body uses only #upcase,
|
|
204
|
+
# so the parameter widens to "anything that responds to upcase",
|
|
205
|
+
# not the nominal String. An override that accepted String only
|
|
206
|
+
# would be *strengthening* the precondition — an LSP violation,
|
|
207
|
+
# and exactly the over-strict shape clause 2 steers away from.
|
|
208
|
+
def shout(thing)
|
|
209
|
+
thing.upcase
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Clause 1 / covariant returns: the body provably returns the
|
|
213
|
+
# receiver, so #dup returns `self` (Array, not the widened Object).
|
|
214
|
+
# Returning Object would *weaken* the postcondition.
|
|
215
|
+
copy = [1, 2, 3].dup
|
|
216
|
+
assert_type("Array", copy)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
The connection to the type-theory appendix's **gradual guarantee**
|
|
220
|
+
([§ "Blame, the gradual guarantee, and trust
|
|
221
|
+
boundaries"](appendix-type-theory.md)) is direct: a system that
|
|
222
|
+
honours the signature rule by construction also satisfies "adding
|
|
223
|
+
an annotation never breaks a previously-passing call site," because
|
|
224
|
+
a correctly-widened parameter and a correctly-narrowed return are
|
|
225
|
+
precisely the annotations that preserve substitutability.
|
|
226
|
+
|
|
227
|
+
## Variance and the signature rule
|
|
228
|
+
|
|
229
|
+
The signature rule *is* a statement about variance, and Rigor
|
|
230
|
+
inherits RBS's variance vocabulary
|
|
231
|
+
([§ "Variance"](appendix-type-theory.md#variance) in the
|
|
232
|
+
type-theory appendix):
|
|
233
|
+
|
|
234
|
+
- **Covariant (`out T`)** — producer position; the return side of
|
|
235
|
+
the signature rule.
|
|
236
|
+
- **Contravariant (`in T`)** — consumer position; the parameter
|
|
237
|
+
side of the signature rule.
|
|
238
|
+
- **Invariant** — both at once; mutable storage.
|
|
239
|
+
|
|
240
|
+
Ruby's mutable containers (`Array`, `Hash`, `Set`) are **invariant**
|
|
241
|
+
in their element type, and this is the canonical place LSP and a
|
|
242
|
+
naive intuition collide. The "obvious" idea that `Array[Integer]`
|
|
243
|
+
should substitute for `Array[Numeric]` is *unsound* the moment a
|
|
244
|
+
caller writes `arr.push(1.5)` — the classic
|
|
245
|
+
covariant-arrays-are-broken cautionary tale (Java's `ArrayStoreException`).
|
|
246
|
+
RBS declares these containers invariant; Rigor honours the
|
|
247
|
+
declaration and so never offers the unsound substitution. The
|
|
248
|
+
signature rule, applied to the *mutating* methods (`push` takes the
|
|
249
|
+
element in a contravariant position; `[]` returns it in a covariant
|
|
250
|
+
one), forces invariance, and Rigor gets this for free by trusting
|
|
251
|
+
RBS rather than re-deriving variance.
|
|
252
|
+
|
|
253
|
+
**Self types** are the signature rule's covariant-return case made
|
|
254
|
+
load-bearing for OO Ruby. RBS's `self` keyword (`def dup: () ->
|
|
255
|
+
self`) means "I return *my own* class," so a subtype's inherited
|
|
256
|
+
method returns the subtype — covariant return, honoured
|
|
257
|
+
automatically. See [§ "F-bounded polymorphism and self
|
|
258
|
+
types"](appendix-type-theory.md#f-bounded-polymorphism-and-self-types)
|
|
259
|
+
for the deeper treatment; the LSP reading is that `self` is the
|
|
260
|
+
mechanism that keeps `Sub#dup` returning `Sub`, never widening back
|
|
261
|
+
to the ancestor and thereby weakening the postcondition.
|
|
262
|
+
|
|
263
|
+
## Preconditions: contravariant parameters and duck typing
|
|
264
|
+
|
|
265
|
+
The behavioral rule "**preconditions may not be strengthened**" is,
|
|
266
|
+
in Ruby, almost a restatement of duck typing. A Ruby method does not
|
|
267
|
+
declare what *class* it wants; it declares what *messages* it sends.
|
|
268
|
+
Any object answering those messages is substitutable — which is
|
|
269
|
+
exactly "do not require more of the caller than necessary."
|
|
270
|
+
|
|
271
|
+
Rigor encodes this with **capability roles** and structural
|
|
272
|
+
interfaces (the clause-2 toolbox):
|
|
273
|
+
|
|
274
|
+
| Body uses | Over-strict (strengthened precondition) | Clause-2 / LSP-friendly |
|
|
275
|
+
| --- | --- | --- |
|
|
276
|
+
| only `#write` | `IO` | `_Writable` (StringIO, Tempfile, mocks all qualify) |
|
|
277
|
+
| only `#upcase` | `String` | a `#upcase`-bearing role |
|
|
278
|
+
| `+`, `-`, `<=>` | `Integer` | `Numeric` |
|
|
279
|
+
| `#to_s`, nil-guarded | `String` | `String \| nil` (body narrows) |
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
# A subtype override that *narrowed* its parameter to IO would
|
|
283
|
+
# strengthen the precondition and break substitutability. Rigor's
|
|
284
|
+
# inferred parameter for the parent is already _Writable, so the
|
|
285
|
+
# LSP-honouring override is also the one Rigor infers by default.
|
|
286
|
+
def dump(stream)
|
|
287
|
+
stream.write(serialize)
|
|
288
|
+
end
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
The **open-world assumption** on `Dynamic[T]` /
|
|
292
|
+
`respond_to?`-narrowed receivers
|
|
293
|
+
([§ "Open-world vs closed-world"](appendix-type-theory.md#open-world-vs-closed-world-assumption))
|
|
294
|
+
is the same instinct at the call site: Rigor does not assume it
|
|
295
|
+
knows the full method set of a dynamic value, so it does not
|
|
296
|
+
manufacture a precondition ("this exact class") the runtime never
|
|
297
|
+
required. Strengthening the precondition would mean firing a false
|
|
298
|
+
positive on working duck-typed code — which collides head-on with
|
|
299
|
+
the project's [false-positive
|
|
300
|
+
discipline](../adr/8-steep-inspired-improvements.md). LSP and "never
|
|
301
|
+
frighten working code" point the same way.
|
|
302
|
+
|
|
303
|
+
## Postconditions: covariant returns and `self` types
|
|
304
|
+
|
|
305
|
+
"**Postconditions may not be weakened**" is clause 1, and it has a
|
|
306
|
+
concrete enforcement surface: **`def.return-type-mismatch`**
|
|
307
|
+
([ADR-8](../adr/8-steep-inspired-improvements.md)). When a method
|
|
308
|
+
carries a declared RBS return type and the body's inferred type
|
|
309
|
+
*cannot satisfy* it, Rigor emits an error:
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
class Repo
|
|
313
|
+
# RBS: def find: (Integer) -> User
|
|
314
|
+
def find(id)
|
|
315
|
+
@cache[id] # inferred: User | nil
|
|
316
|
+
end
|
|
317
|
+
# def.return-type-mismatch — the body can return nil, which
|
|
318
|
+
# weakens the declared "-> User" postcondition.
|
|
319
|
+
end
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
This is the postcondition rule applied to a single method against
|
|
323
|
+
its *own* declared contract. Note the scope precisely (it matters
|
|
324
|
+
for the next section): `def.return-type-mismatch` checks the body
|
|
325
|
+
against the method's **own** RBS signature, not against a
|
|
326
|
+
superclass's signature. The complementary check — comparing a
|
|
327
|
+
subtype's override against the supertype's declared return to verify
|
|
328
|
+
covariance across the hierarchy — shipped in v0.1.15 as the
|
|
329
|
+
`def.override-*` rule family (§ "Cross-hierarchy override
|
|
330
|
+
compatibility" below). The robustness principle keeps each
|
|
331
|
+
*individually authored* signature honest; the override family then
|
|
332
|
+
verifies that the authored child contract substitutes for the
|
|
333
|
+
authored parent contract.
|
|
334
|
+
|
|
335
|
+
The covariant-return half is also why **self types** earn their
|
|
336
|
+
keep here, mirrored from the variance section: `def dup: () -> self`
|
|
337
|
+
guarantees a subtype's inherited `dup` keeps returning the subtype.
|
|
338
|
+
A signature that widened `dup`'s return to `Object` in a subtype
|
|
339
|
+
would weaken the postcondition; `self` makes the LSP-correct
|
|
340
|
+
behaviour the *only* behaviour expressible.
|
|
341
|
+
|
|
342
|
+
## Invariants and the history constraint
|
|
343
|
+
|
|
344
|
+
The third and fourth behavioral rules — **invariants preserved** and
|
|
345
|
+
the **history constraint** — are where Rigor's coverage is
|
|
346
|
+
*partial*, and it is worth being precise about what is and is not
|
|
347
|
+
modelled.
|
|
348
|
+
|
|
349
|
+
Rigor has an internal **effect model**
|
|
350
|
+
([§ "Effect systems"](appendix-type-theory.md#effect-systems)) that
|
|
351
|
+
tracks mutation, non-local exit, and closure escape. The
|
|
352
|
+
user-visible consequences relevant to invariants:
|
|
353
|
+
|
|
354
|
+
- **Mutation invalidates narrowing (fact stability).** A narrowed
|
|
355
|
+
fact about a variable is dropped after a mutating call, because
|
|
356
|
+
the mutation might have broken the property the narrowing relied
|
|
357
|
+
on. This is the *local, within-a-method* analogue of "an
|
|
358
|
+
invariant must survive every operation" — Rigor refuses to keep
|
|
359
|
+
trusting a fact a mutation could have falsified.
|
|
360
|
+
- **`frozen?` narrowing.** Rigor narrows on `frozen?`, and a frozen
|
|
361
|
+
object is one whose state-history is closed — the strongest form
|
|
362
|
+
of the history constraint Ruby offers. Immutable value objects
|
|
363
|
+
(`Data.define`, frozen literals) are the idiom that satisfies the
|
|
364
|
+
history constraint by construction, and Rigor types them
|
|
365
|
+
precisely.
|
|
366
|
+
|
|
367
|
+
What Rigor does **not** do: it does not verify, across a class
|
|
368
|
+
hierarchy, that a subtype's added methods preserve a supertype's
|
|
369
|
+
declared invariant, nor does it enforce the Liskov-Wing history
|
|
370
|
+
constraint as a subtyping check (the rule that forbids a mutable
|
|
371
|
+
`Point` from subtyping an immutable `Point`). Ruby has no surface
|
|
372
|
+
for declaring such invariants, and inferring them is well outside
|
|
373
|
+
the AST-walking, no-false-positives envelope. The history-constraint
|
|
374
|
+
*spirit* is honoured operationally (fact stability, `frozen?`); the
|
|
375
|
+
*subtyping check* is not implemented.
|
|
376
|
+
|
|
377
|
+
## Behavioral vs nominal subtyping in Ruby
|
|
378
|
+
|
|
379
|
+
LSP is a statement about **behavioral** subtyping, but Ruby's
|
|
380
|
+
runtime dispatch is **nominal-and-mixin**: `is_a?` consults the
|
|
381
|
+
ancestor chain, and `Comparable` / `Enumerable` are behavioral
|
|
382
|
+
contracts delivered *through* a nominal mixin (include the module,
|
|
383
|
+
implement the one required method, inherit the rest). Ruby thus
|
|
384
|
+
blends the two — nominal inheritance for identity, module mixins for
|
|
385
|
+
shared behaviour.
|
|
386
|
+
|
|
387
|
+
Rigor's **nominal-first-with-structural-facets** stance
|
|
388
|
+
([§ "Nominal vs structural typing"](appendix-type-theory.md#nominal-vs-structural-typing))
|
|
389
|
+
matches this exactly:
|
|
390
|
+
|
|
391
|
+
- Nominal classes are the unit of modelling and the stable
|
|
392
|
+
attachment point for declared contracts.
|
|
393
|
+
- `interface _Comparable`-style structural shapes and capability
|
|
394
|
+
roles capture the behavioral, duck-typed substitutability that
|
|
395
|
+
pure nominal subtyping misses.
|
|
396
|
+
|
|
397
|
+
The Ruby community already teaches LSP without the type theory.
|
|
398
|
+
Sandi Metz's *Practical Object-Oriented Design in Ruby* frames the
|
|
399
|
+
"L" of SOLID as: **subclasses should be substitutable for their
|
|
400
|
+
superclasses**, and the practical test is that callers written
|
|
401
|
+
against the superclass keep working unchanged. That is Liskov's 1987
|
|
402
|
+
sentence in plain Ruby. Rigor's contribution is to make the
|
|
403
|
+
*signature half* of that discipline machine-checkable, and to do so
|
|
404
|
+
without asking the programmer to write a single annotation, because
|
|
405
|
+
the robustness principle infers the LSP-correct parameter and return
|
|
406
|
+
shapes automatically.
|
|
407
|
+
|
|
408
|
+
## Where Ruby lets you violate LSP — and what Rigor does
|
|
409
|
+
|
|
410
|
+
Ruby imposes **no** static override discipline. You can override a
|
|
411
|
+
method with a different arity, a narrower parameter, an unrelated
|
|
412
|
+
return, or a brand-new exception, and the interpreter will not
|
|
413
|
+
complain until (and unless) a runtime path hits the incompatibility.
|
|
414
|
+
Every one of these is a potential LSP violation:
|
|
415
|
+
|
|
416
|
+
```ruby
|
|
417
|
+
class Animal
|
|
418
|
+
def speak(volume) = "..." * volume
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
class Mute < Animal
|
|
422
|
+
def speak # arity narrowed: strengthened precondition
|
|
423
|
+
raise NotImplementedError # new exception: exception-rule violation
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
A *sound* checker would flag the override. Rigor, by its
|
|
429
|
+
[no-false-positives stance](../adr/8-steep-inspired-improvements.md),
|
|
430
|
+
does **not** bark at every override — because in Ruby, overriding
|
|
431
|
+
with a changed shape is frequently *intentional and correct*
|
|
432
|
+
(template-method refinements, `method_missing`, DSL-driven
|
|
433
|
+
redefinition, `define_method`). Treating every shape change as an
|
|
434
|
+
LSP error would frighten enormous amounts of working code.
|
|
435
|
+
|
|
436
|
+
Instead Rigor fires only where it can *prove* a contract is broken:
|
|
437
|
+
|
|
438
|
+
- `call.argument-type-mismatch` when a call site provably passes
|
|
439
|
+
something a declared parameter cannot accept.
|
|
440
|
+
- `def.return-type-mismatch` when a body provably cannot produce its
|
|
441
|
+
*own declared* return.
|
|
442
|
+
|
|
443
|
+
Both are local proofs against an authored contract, never a
|
|
444
|
+
speculative cross-hierarchy substitutability judgment. This is the
|
|
445
|
+
LSP-pragmatic position: enforce the parts you can prove, infer the
|
|
446
|
+
LSP-correct shape everywhere you author one, and stay silent on the
|
|
447
|
+
override-compatibility question that Ruby idioms routinely and
|
|
448
|
+
legitimately blur.
|
|
449
|
+
|
|
450
|
+
The deeper point: because the robustness principle *infers*
|
|
451
|
+
wide parameters and narrow returns by default, the most common
|
|
452
|
+
LSP-correct design is also the *path of least resistance* under
|
|
453
|
+
Rigor. A programmer following Rigor's inferred shapes is, without
|
|
454
|
+
trying, writing overrides that don't strengthen preconditions or
|
|
455
|
+
weaken postconditions. Rigor nudges toward LSP compliance through
|
|
456
|
+
its defaults rather than policing it through diagnostics.
|
|
457
|
+
|
|
458
|
+
## Cross-hierarchy override compatibility
|
|
459
|
+
|
|
460
|
+
Since v0.1.15 ([ADR-35](../adr/35-override-signature-compatibility.md)),
|
|
461
|
+
Rigor *does* compare an override against the contract it inherits —
|
|
462
|
+
the signature rule applied across a project-defined class/module
|
|
463
|
+
hierarchy. Three rules make up the `def.override-*` family:
|
|
464
|
+
|
|
465
|
+
- **`def.override-param-narrowed`** — parameter contravariance: an
|
|
466
|
+
override may not strengthen its precondition by narrowing a
|
|
467
|
+
parameter type.
|
|
468
|
+
- **`def.override-return-widened`** — return covariance: an override
|
|
469
|
+
may not weaken its postcondition by widening a return type.
|
|
470
|
+
- **`def.override-visibility-reduced`** — an override may not reduce
|
|
471
|
+
inherited visibility (public → protected/private).
|
|
472
|
+
|
|
473
|
+
The family is the load-bearing-discipline counterpart to the
|
|
474
|
+
robustness principle: where the principle *biases inferred*
|
|
475
|
+
signatures toward substitutability, these rules *verify authored*
|
|
476
|
+
ones. They are gated for false-positive discipline — both the
|
|
477
|
+
override and the shadowed ancestor must carry an authored signature
|
|
478
|
+
(hand-written / rbs-inline / bundled RBS; inference-only either side
|
|
479
|
+
stays silent), only a proven (`:no`) violation fires, and severity
|
|
480
|
+
maps through `severity_profile:` (`lenient` → off, `balanced` →
|
|
481
|
+
warning, `strict` → error). The ancestor scope is the superclass
|
|
482
|
+
chain plus included/prepended modules, resolved cross-file.
|
|
483
|
+
|
|
484
|
+
The escape hatch for a *legitimate* specialization that looks like a
|
|
485
|
+
narrowing is **generics first, not suppression**: declare the parent
|
|
486
|
+
with a bounded type parameter (`interface _Consumer[T < Message]`)
|
|
487
|
+
and have the subtype bind it (`include _Consumer[SendMailMessage]`),
|
|
488
|
+
so the override matches the *instantiated* contract — the same
|
|
489
|
+
resolution PHPStan reaches for in [*Generics in PHP using
|
|
490
|
+
PHPDocs*](https://medium.com/@ondrejmirtes/generics-in-php-using-phpdocs-14e7301953)
|
|
491
|
+
("even Barbara Liskov is happy with it"). Second is keeping the
|
|
492
|
+
declared parameter wide and recovering the narrow type in the body
|
|
493
|
+
via occurrence typing; `# rigor:disable def.override-*` is the last
|
|
494
|
+
resort.
|
|
495
|
+
|
|
496
|
+
## What Rigor does NOT check (LSP-wise)
|
|
497
|
+
|
|
498
|
+
For completeness, the LSP obligations Rigor does **not** statically
|
|
499
|
+
enforce in v0.1.x — named here so you can stop looking:
|
|
500
|
+
|
|
501
|
+
- **The inferred-side of cross-hierarchy override compatibility.**
|
|
502
|
+
The shipped `def.override-*` family (§ "Cross-hierarchy override
|
|
503
|
+
compatibility" above) gates on *both sides carrying an authored
|
|
504
|
+
signature*. The complementary case — checking a child's *inferred*
|
|
505
|
+
return against an authored parent return ([ADR-35](../adr/35-override-signature-compatibility.md)
|
|
506
|
+
slice 5) — plus RBS-only-ancestor reach and singleton (`def self.`)
|
|
507
|
+
method coverage remain deferred (demand-driven, higher
|
|
508
|
+
false-positive surface).
|
|
509
|
+
- **Exception compatibility.** The signature rule's "no new
|
|
510
|
+
exceptions in a subtype" is not checked. Rigor has an internal
|
|
511
|
+
exception/non-local-exit effect
|
|
512
|
+
([§ "Effect systems"](appendix-type-theory.md#effect-systems))
|
|
513
|
+
but does not surface a `raise`-set per method or compare it across
|
|
514
|
+
overrides. RBS has no widely-used `raises` clause to check
|
|
515
|
+
against.
|
|
516
|
+
- **Behavioral pre/postcondition formulas (Design by Contract).**
|
|
517
|
+
Meyer-style `require` / `ensure` predicate contracts, and their
|
|
518
|
+
inheritance rules, have no Rigor surface. The closest analogues
|
|
519
|
+
are refinement carriers (`non-empty-string`, `positive-int`, …) at
|
|
520
|
+
the *type* level and `RBS::Extended` predicate directives — neither
|
|
521
|
+
is a general contract formula.
|
|
522
|
+
- **The history constraint as a subtyping check.** Honoured
|
|
523
|
+
operationally via fact stability + `frozen?` (§ "Invariants"); not
|
|
524
|
+
enforced as a hierarchy-level rule.
|
|
525
|
+
- **Invariant inference across a class hierarchy.** No declaration
|
|
526
|
+
surface, outside the AST-walking envelope.
|
|
527
|
+
|
|
528
|
+
If any of these becomes important to the user base, it will be
|
|
529
|
+
discussed in an ADR before any implementation slice — the same
|
|
530
|
+
discipline the type-theory appendix's [§ "What Rigor does NOT
|
|
531
|
+
model"](appendix-type-theory.md#what-rigor-does-not-model) records.
|
|
532
|
+
|
|
533
|
+
## A short reading list
|
|
534
|
+
|
|
535
|
+
- Liskov, B. "Data Abstraction and Hierarchy." *OOPSLA '87
|
|
536
|
+
Addendum / SIGPLAN Notices*, 1988. The keynote that stated the
|
|
537
|
+
substitution intuition.
|
|
538
|
+
- Liskov, B. & Wing, J.M. "A Behavioral Notion of Subtyping." *ACM
|
|
539
|
+
TOPLAS*, 1994. The precise formulation — signature rule + the
|
|
540
|
+
pre/postcondition/invariant/history behavioral rules. The
|
|
541
|
+
reference for everything on this page.
|
|
542
|
+
- Meyer, B. *Object-Oriented Software Construction*, 2nd ed.
|
|
543
|
+
Prentice Hall, 1997. Design by Contract and the inheritance rules
|
|
544
|
+
for `require` / `ensure` / `invariant` — the behavioral rule
|
|
545
|
+
stated as a contract discipline.
|
|
546
|
+
- Cardelli, L. & Wegner, P. "On Understanding Types, Data
|
|
547
|
+
Abstraction, and Polymorphism." *ACM Computing Surveys*, 1985. The
|
|
548
|
+
variance and subtyping vocabulary the signature rule is phrased
|
|
549
|
+
in.
|
|
550
|
+
- Metz, S. *Practical Object-Oriented Design in Ruby* (POODR).
|
|
551
|
+
Addison-Wesley, 2nd ed. 2018. The Ruby-community statement of the
|
|
552
|
+
"L" in SOLID — substitutability as a practical design test,
|
|
553
|
+
no type theory required.
|
|
554
|
+
- See also the companion [§ "Robustness principle (Postel's law for
|
|
555
|
+
types)"](../type-specification/robustness-principle.md) (normative)
|
|
556
|
+
and [ADR-5](../adr/5-robustness-principle.md) (rationale) — the
|
|
557
|
+
Rigor surface this page maps LSP onto.
|
|
558
|
+
|
|
559
|
+
## What's next
|
|
560
|
+
|
|
561
|
+
- [Appendix — Connections to type theory](appendix-type-theory.md)
|
|
562
|
+
for the formal scaffolding this page leans on — variance, the
|
|
563
|
+
gradual guarantee, self types / F-bounded polymorphism, the
|
|
564
|
+
soundness vs completeness trade-off.
|
|
565
|
+
- [Chapter 6 — Classes](06-classes.md) for instance-side vs
|
|
566
|
+
class-side typing, `self`, and `Data.define` — the OO surface LSP
|
|
567
|
+
is about.
|
|
568
|
+
- [Chapter 7 — RBS and `RBS::Extended`](07-rbs-and-extended.md) for
|
|
569
|
+
authoring the declared contracts `def.return-type-mismatch` checks
|
|
570
|
+
against.
|
|
571
|
+
- [Chapter 8 — Understanding errors](08-understanding-errors.md) for
|
|
572
|
+
the `def.return-type-mismatch` / `call.argument-type-mismatch`
|
|
573
|
+
rules and severity profiles.
|
|
574
|
+
|
|
575
|
+
If you want to compare against another *tool* rather than the
|
|
576
|
+
*principle*, the sibling appendices cover
|
|
577
|
+
[TypeScript](appendix-typescript.md),
|
|
578
|
+
[PHPStan](appendix-phpstan.md),
|
|
579
|
+
[mypy / Pyright](appendix-mypy.md),
|
|
580
|
+
and [Steep](appendix-steep.md).
|