Trim AGENTS.md to what the code does not already say
Commands lists only the everyday tasks and points at `rake -T` for the
rest. Layout keeps the entries whose role a filename does not give.
Gotchas are grouped, and each keeps its rule while its argument goes
where it already lives: the Store class comment, test_helper.rb,
vcard.rb and the plans. The phrases lib cites AGENTS.md by are kept.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCkrBu2KqN7ncT3kvjCvfh
diff --git a/AGENTS.md b/AGENTS.md
index c173e90..e657c84 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -17,39 +17,12 @@ rake steep # Type check lib against the RBS comments in it
rake fixtures # Re-record response fixtures from current behavior
rake fixtures:extract # A logged exchange's request as a fixture step
# (EXCHANGE=id STEP=test/fixtures/<recording>/NN-name)
-rake index:rebuild # Derive the parsed index again from the stored cards
-rake contacts:reid # Move contacts whose ids are not server-minted
- # onto minted ones, rewriting each card's UID
-rake book:name # Name a login's book and rename its sync group
- # (LOGIN=login NAME=name; no NAME clears it)
-rake db:dump # Write the cards, birthdays, groups, and books
- # to data/dump and commit them to the repository
- # there (DUMP=path to move it)
-rake dev # Dev server, seeded from test/fixtures/cards into a
- # throwaway tmpdir on every start, reloading on change
- # (needs fd and entr)
-rake console # irb with the store open on the configured database
- # (console.rb loads it), after db:dump commits a
- # safety net for what the session might do there
-rake profile:install # Download carddav.mobileconfig from the app and
- # stage it for approval
-rake profile:remove # Remove the installed profiles pointing at it
-rake alpine:vendor # Refresh the vendored Alpine from npm
- # (ALPINE=version)
-rake gloss:vendor # Refresh the vendored Gloss CSS from a local
- # checkout (GLOSS=path)
+rake dev # Dev server on the fixture book, reloading on
+ # change (needs fd and entr)
```
-`rake profile:*` needs `PRO_TACTS_HOSTNAME` and touches installed system
-profiles, so leave those to the user.
-
-Importing is not a task at all: a .vcf is uploaded at `/import` in the
-app and saved there contact by contact, each row's own Save writing it
-through the store (docs/plans/2026-09-21-import-a-vcf.md,
-docs/plans/2026-09-23-merging-on-import.md). The `import:*` tasks and
-the Swift reader they drove Contacts.app with are gone.
-
-## Task management
+`rake -T` lists the rest. `rake profile:*` needs `PRO_TACTS_HOSTNAME`
+and touches installed system profiles, so leave those to the user.
The backlog lives in `ranger`, not GitHub Issues. `RANGER_DEFAULT_BACKLOG`
is already `pro-tacts` — don't pass `--backlog` or verify it.
@@ -58,291 +31,157 @@ is already `pro-tacts` — don't pass `--backlog` or verify it.
```
lib/pro_tacts/
-├── web.rb # The Roda app: the identity gate and the root;
-│ # each first segment is a hash_branch in web/
-├── web/dav.rb # Discovery and the address book: every DAV route
-│ # and response body
-├── dav_xml.rb # The elements those bodies are written with, one
-│ # object per namespace
-├── web/contacts.rb # The card browser and its editor
-├── web/groups.rb # The group screens and their editor
-├── web/setup.rb # The device setup screen and its profile
-├── web/import.rb # The import screens: a .vcf, uploaded and saved
-├── web/api.rb # JSON for scripts: the group list
-├── import/ # Reading an uploaded .vcf, matching its cards to
-│ # contacts the book has, and writing them
-├── admin/ # The Phlex views those screens render, plus
-│ # card_form.rb, which reads a form back into a card
-├── store.rb # Sequel over SQLite: cards, the change log, the
-│ # group change log, and the derived index
-├── contact.rb # the one model of a contact: id, vCard, etag,
-│ # and the structured accessors over the card
-├── group.rb # A group as a screen reads one, and the `sync:`
-│ # rules: which names put cards in a book, and
-│ # everyone's
-├── edited_contact.rb # A submitted card read against the contact it
-│ # replaces: the card, birthday, and group edits
-│ # Store#put writes
-├── birthday.rb # A partial date, the one fact no card can hold
-├── birthday_line.rb # What a submitted BDAY line is, for EditedContact
-├── card_diff.rb # The lines a write added and removed
-├── sync_token.rb # The token a syncing client carries: minted
-│ # at a ctag for a book, and read back
-├── vcard.rb # vCard 3.0 escaping, and what a card is
-├── vcard/parser.rb # One card's bytes into lines
-├── proxy_auth.rb # The login, off the header the proxy writes
-├── stub_login.rb # That header, filled in where no proxy stands
-│ # (demo.ru only)
-├── change_id.rb # Ids in the letters jj spells a change id with
-├── config.rb # Every environment read in the app
-├── profile.rb # carddav.mobileconfig generation
-├── exchange_log.rb # Failed DAV exchanges, whole, under an id Sentry
-│ # carries
-├── refusal_alerts.rb # DAV refusals as Sentry warnings, one per route
-└── warnings.rb # $VERBOSE off for a block, for the phlex requires
-config.ru # The deployment: the composition root
-demo.ru # The fixture book behind a stub login, served by
- # the Fly preview and main apps (fly.toml)
+├── web.rb # The Roda app; each first path segment is a
+│ # hash_branch in web/
+├── web/dav.rb # Every DAV route and response body, written
+│ # through dav_xml.rb
+├── web/*.rb # The admin screens, one file per section
+├── admin/ # The Phlex views they render; card_form.rb reads
+│ # a form back into a card
+├── import/ # An uploaded .vcf: read, matched, and written
+├── store.rb # Sequel over SQLite, and every write
+├── contact.rb # The one model of a contact
+├── edited_contact.rb # A submission read against the contact it
+│ # replaces, for Store#put
+├── vcard.rb # A card: its bytes, and the writer's half
+└── vcard/parser.rb # The reading half
+config.ru # The deployment, and the composition root
+demo.ru # The fixture book behind StubLogin: the Fly apps
dev.ru # demo.ru, marked as a dev run, for `rake dev`
-lib/roda/plugins/dav_verbs.rb # PROPFIND and REPORT routing verbs
-lib/sequel/extensions/sole.rb # `first`, minus the ambiguity
-db/migrations/ # Sequel migrations, run on every store open
+db/migrations/ # Run on every store open
sig/ # RBS for what an inline comment cannot say
-docs/DESIGN.md # Who the admin screens are for, and how they look
+docs/DESIGN.md # Who the admin screens are for
docs/apple-contacts.md # What the Apple clients do off-spec
docs/rfcs/ # Vendored spec texts the code cites
docs/plans/ # Dated design records
-test/fixtures/cards/ # Seed cards for the test database
-test/fixtures/macos-exchange/ # A real client session, replayed
-test/fixtures/ios-exchange/ # Where a second client differs from it
+test/fixtures/ # Seed cards, and real client sessions replayed
```
## Fixtures
`test/fixtures/macos-exchange/` replays the confirmed-working macOS
Contacts session, and `test/fixtures/ios-exchange/` the steps where iOS
-sends something macOS does not — a namespace prefix spelled differently,
-a header it omits, a verb macOS never sends. Within each step directory
-the two files have opposite standing:
+differs from it. Within each step directory the two files have opposite
+standing:
- `request` files are evidence of what a real client sent. Edit them only
when a new recorded session supersedes this one.
- `response` files are a snapshot of current behavior. Never hand-edit
- them; change the app, run `rake fixtures`, and review the diff.
+ them; change the app, run `rake fixtures`, and read the diff before
+ regenerating. One you didn't intend is the suite doing its job.
-A fixture diff you didn't intend is the test suite doing its job. Read it
-before regenerating.
-
-`test/fixtures/cards/` holds the vCards the test database is seeded from.
-The database itself is built in a throwaway tmpdir on every run and is
-not a fixture; edit a `.vcf` to change what the replay serves.
+`test/fixtures/cards/` seeds the test database, which is built in a
+tmpdir on every run; edit a `.vcf` to change what the replay serves.
+Seeding goes through `Store#put` without `client:`, so a test store has
+no `sync:*` group; a test that needs everyone's book makes it.
## Gotchas
-- Response bodies in `web/dav.rb` are built with Nokogiri's builder through
- `DavXml`, whose methods are the only elements the server can send. Inside
- an element's block, `it` is the builder, not an enclosing block's value,
- so name the outer block's parameter.
-- Every request needs a `Remote-User` header or it gets a 401.
- `StubLogin` fills it with `alpha@example.com` when a request has none,
- so under `rake dev` pass it only to ask as someone else. The security
- of that rests on the app being reachable only through a proxy that
- writes the header itself. `StubLogin` belongs only in `demo.ru`, in
- front of the fixture book: never put it in `config.ru`, and never
- serve real data without the proxy in front.
-- DAV exchanges that went wrong (a status of 400 or more but 401, a
- crash, or a report to Sentry) are written whole to `log/exchange.log`,
- each line prefixed by the id Sentry carries as the `exchange` tag.
- When implementing something a client asked for, look there first, and
- promote an exchange into `test/fixtures` with `rake fixtures:extract`,
- which strips the identifying headers.
-- No request body reaches Sentry: `send_default_pii` is off in
- `config.ru`, because card content never leaves the machine. The URL,
- headers, exception messages, and `capture_message` text still do, so
- keep card content out of all four.
-- Don't hide errors. A `rescue` that swallows an exception and returns a
- fallback value reads as defensive but is actually the opposite: it turns
- a bug or corrupt data into a silently wrong screen instead of a loud one.
- Every request already runs under `Sentry::Rack::CaptureExceptions`, so an
- unexpected failure that's allowed to raise is reported and visible —
- rescuing it locally "to be safe" makes it neither. Rescue only what's an
- ordinary, anticipated case with a real fallback to show (e.g. no row for
- a requested id — see `Store#contact`'s 404 path), never "this might
- fail, better catch it." A rescue that catches must still send it to
- Sentry — `Sentry.capture_exception` at the minimum.
-- `config.ru` must stay ASCII-only; a test enforces it, because boot crashes
- under a C locale otherwise. Watch for em dashes in comments.
-- `RUBYOPT=--enable-frozen-string-literal` is set in `.ramekin/config.kdl`.
- String literals are frozen; mutating one raises.
-- `supported_http_methods` in `config/puma.rb` *replaces* Puma's default
- method list rather than extending it. Any method the app answers must be
- named there or Puma returns 501 from the HTTP parser, before Rack runs —
- so the request never reaches the app and `ExchangeLog` cannot log it. Adding a route is two files, not one.
-- Application code reads configuration through `ProTacts.config` only; add
- a method to `config.rb` rather than reaching for `ENV`. The Rakefile is
- outside that rule and reads `PRO_TACTS_HOSTNAME` directly.
-- `config.ru` is the composition root. Requiring the app must stay free of
- side effects — the test helper depends on that, and `config.ru` never
- runs under test.
-- The code cites RFC sections next to each handler. Verify a section
- number against the vendored text in `docs/rfcs/` before writing it; do
- not cite from memory. Two properties macOS needs (`getctag`,
- `push-transports`) are in no RFC at all.
+### DAV
+
+- Response bodies are built with Nokogiri's builder through `DavXml`,
+ whose methods are the only elements the server can send. Inside an
+ element's block, `it` is the builder, so name an outer block's
+ parameter.
- Adding a property to a response is a real decision, not a freebie. The
current set was found by removing properties until the client broke.
-- `cards` and `changes` carry timestamps; the index tables deliberately
- do not, because their rows are thrown away and rebuilt. SQLite writes
- the stamps, so nothing in Ruby should set one.
-- Schema changes are Sequel migrations under `db/migrations/`, applied
- by `Store#migrate` on every open — there is no separate migrate task
- to forget, and no way to serve from a database a deploy left behind.
- Never edit a migration that has run anywhere; add the next one.
-- Those tables are `STRICT`, so every string column needs `text: true`:
- Sequel's plain `String` is a varchar, which STRICT refuses. Strings
- reach the store as UTF-8 by contract, and the adapter enforces it: the
- sqlite3 gem encodes every bound value to UTF-8, so binary-flagged
- bytes above 7 bits raise at the bind, while bytes that are not UTF-8
- at all are refused by SQLite on the insert. The one binary input is
- the request body — Rack requires input in ASCII-8BIT and
- Rack::RewindableInput enforces it — relabelled to UTF-8 where it is
- read, in `write_card`. Path-derived ids need no relabel: Puma hands
- PATH_INFO over still percent-encoded (set verbatim from
- REQUEST_PATH), so an id off the wire is ASCII.
-- The app is handed its store rather than reaching for one:
- `config.ru` builds it and sets `ProTacts::Web.store`, and a test does
- the same with a throwaway. There is no global `ProTacts.store`, so
- nothing in a request depends on load order or on a lazy memo several
- threads could race into. `Store.connect` is for a database that is not
- the one being served — a fixture, a test, a task pointed elsewhere.
-- `test/test_helper.rb` sets `ENV` before it requires the app, and the
- requires cannot all move to the top because of it: the app reads
- configuration as it loads, since the exchange log is given its path at
- class-definition time. Set it afterwards and the failed exchanges land
- in `log/` instead of a tmpdir.
-- Only six things in the database cannot be rebuilt: the cards, the
- change log, the birthdays, the groups, the books, and the group
- change log — a partial date has no vCard 3.0 spelling, so the
- birthday lives beside its card rather than in it
- (docs/plans/2026-08-31-partial-birthdays.md);
- a group's membership and the lines it lends are facts no stored card
- carries (docs/plans/2026-08-24-vcard-storage-and-groups.md);
- only the `books` row says whose book a name is for
- (docs/plans/2026-09-19-books-in-the-dump.md); and the group change
- log's detail is a fact about a write, gone the moment it lands
- (docs/plans/2026-09-23-group-change-log.md). Everything else
- is a projection of the cards that `rake index:rebuild` will make
- again, so no repair is ever needed for it. A write that touches a
- card must land its change-log entry in the same transaction,
- because a client's sync token silently skips whatever the log
- missed — and a write that touches a group must land its
- group-change-log entry there too, because a history with a hole in
- it is silently wrong the same way.
-- A read whose filter is meant to identify one row uses `sole`, not
- `first` — `first` answers with one of several rather than saying the
- filter was too loose. `sole` raises either way it is wrong:
- `Sequel::NoMatchingRow` for none, `Sequel::Sole::TooManyRows` for
- more. A caller for whom no row is ordinary rescues the first, which is
- what `Store#contact` does for the 404 path. The store loads the
- extension on every open.
-- Rescue `Sequel::NoMatchingRow` at the method, around its one
- expression; do not fold it into a read that returns nil, which only
- moves the check into a `row &&` at every caller. The exception is a
- method whose rescue would also wrap a transaction and swallow what
- that raises: read with `first` and return early there
- (`Store#delete_group`).
-- An etag is derived from the card, never stored beside it: `Contact`
- hashes what it serves, and its constructor is the only way to make
- one — no second path takes an etag on trust. The etag in `changes` is
- the exception and is not the same fact — it is what the card hashed
- to at that write, which nothing can recompute once the card moves on.
- Its `diff` is the same kind of fact and the same kind of exception
- (`CardDiff`): the lines that write added and removed, of the card as
- served, so the etag and the diff describe one download. It records a
- card's lines and not its bytes — folding is normalized away — so it
- says what a write did without being able to rebuild what it did it
- to.
-- Nothing above `vcard/parser.rb` handles a parse error, and nothing
- should start. `Parser.lines` is the only read: a line that will not
- read comes back as a Line with no property. The card is served from
- its bytes regardless, `VCard` and `Contact` answer from the lines
- that read, and the index holds what this server understood. There is
- no repair to make, which is why no caller is asked to look for one.
-- The one exception is the arrival reports at the PUT, and it is not
- handling: a broken assumption is news that something this server was
- built on is wrong and reports as an error, and an unreadable line is
- ordinary bad input and warns (`Web#report_broken_assumptions`,
- `Web#report_unreadable_lines`). They live at the PUT because that is
- the arrival — a read happens on every page load, and one bad card
- must not alert once per page view.
-- `vcard.rb` no longer raises on what it does not recognize, and must
- not start again. It used to render a hand-edited format, where an
- unknown key meant a typo silently losing data; it now reads stored
- cards, where an unknown property means a client using the spec, and
- RFC 6352 section 6.3.2.2 requires keeping it.
-- `escape` and `header_of` in `vcard.rb` are the writer's half of the
- module, and the web editor's saves are rebuilt through them
- (`admin/card_form.rb`). There is no `fold` beside them: a served card
- is stored bytes going out untouched, and the editor writes the one
- line it changed, so nothing has ever had a logical line to fold.
- `Parser.unfold` is the reading half and does have callers.
-- `vcard` names a `VCard` and nothing else, and a card with no
- qualifier to give it is named `vcard`. A `vcard` holding a card's
- bytes is the confusion the rule exists to stop: the two sit one
- `VCard.new` apart, and at a PUT that gap is the whole question —
- the body arrives as octets and everything below the route wants
- the card — so the octets are `bytes` (`Web#write_card`). A card
- that needs saying which one keeps its own word (`stored`, `own`,
- `rest`). The `cards` column and the `text/vcard` media type spell
- it the same way and are neither of them variables.
-- A wrong name is fixed where it starts, in the change at hand: the
- migration that renames the column, and every identifier that reads
- it, rather than a follow-up. Check what the new word already means in
- that class before taking it.
-- A class that is built once and only read back is a function wearing
- a constructor, and it needs a noun the domain does not have. Put the
- logic as a `def self.` on the module or class whose vocabulary it
- already uses, helpers under `private_class_method` (`CardDiff`,
- `Birthday`, `Admin::Format`). A new class holds a real domain thing
- (`EditedContact` holds the contact before and the submission). If no
- name fits, ask whether the thing should exist at all.
-- A screen, step, or button that only restates what another page holds
- is deleted, not redesigned: land the flow on the real thing (the
- group, the list, the record) instead. See docs/DESIGN.md.
-- `docs/plans/` entries are dated records of what was decided then. Write
- a new one rather than editing an old one to match current behavior.
-- Comments carry the reasoning; the code carries the rest. One that
- restates the line below it has nothing to say, and neither does a
- test comment that restates its test name — the names in `test/` are
- sentences already. Where the reasoning is already written down, cite
- it rather than re-derive it: a `docs/plans/` entry and the section
- within it, an RFC section, or the method that owns the rule. Two
- copies of an argument drift apart, and the plan holds the whole of
- it.
-- Types live in the code, as RBS comments: `#:` above a method for its
- type, `# @rbs` for instance variables and skips, `#:` at the end of a
- line for a constant or an assertion. `rake steep` checks them, and
- `sig/` holds only what that syntax cannot express — the gems, which
- ship no signatures, and the classes the inline parser refuses. Each
- file there says which limit put it there; see
- docs/plans/2026-08-20-type-checking.md.
-- An instance variable declaration has to be the first thing in the
- class body. Further down it is reported as an unused annotation.
-- Steep refuses three shapes that Ruby accepts. A bare `[]` or `{}`
- needs a type unless it lands in a typed parameter: put it on its own
- line with `#: Hash[String, Array[String]]` at the end. A literal of
- several elements is an `Array`, not a tuple, so one meant as
- `[String, String]` says so the same way. And a local that is nil on
- one branch stays nilable after a later `is_a?` check, so return on
- the nil before the value is made.
-- Tests seed their store through `Store#put` without `client:`, so a
- test store has no `sync:*` group. A test that needs everyone's book
- makes it.
-- Actions in `.github/workflows/` are pinned by commit, and the
- comment beside a pin names a tag that is that commit, never a
- branch: zizmor flags a mismatch as a code scanning comment while its
- job stays green. `git ls-remote --tags` confirms the pair.
-- `rake steep` needs a UTF-8 locale, which comes from the environment
- rather than anything in this repo. RBS reads source in the default
- external encoding, so under a C locale the em dashes in these comments
- are invalid bytes and the parse dies on them. The same footgun the
- ASCII-only rule in `config.ru` exists for.
+- Cite RFC sections from the vendored text in `docs/rfcs/`, never from
+ memory. `getctag` and `push-transports` are in no RFC at all.
+- `supported_http_methods` in `config/puma.rb` *replaces* Puma's list:
+ a method not named there gets a 501 before Rack runs, so adding a verb
+ is two files.
+- Every request needs a `Remote-User` header or it gets a 401. That is
+ safe only because a proxy writes the header. `StubLogin` fills it in
+ for the fixture book in `demo.ru` and nowhere else: never in
+ `config.ru`, and never in front of real data.
+- Failed DAV exchanges are written whole to `log/exchange.log` under the
+ id Sentry carries as the `exchange` tag. Look there first when a
+ client asks for something, and promote one with
+ `rake fixtures:extract`.
+
+### Errors
+
+- Don't hide errors. Every request runs under Sentry, so a failure
+ allowed to raise is reported; a rescue "to be safe" turns it into a
+ silently wrong screen. Rescue only an ordinary case with a real
+ fallback, and report anything else you catch with
+ `Sentry.capture_exception`.
+- A read meant to find one row uses `sole`, which raises on none or
+ several, and a caller for whom none is ordinary rescues
+ `Sequel::NoMatchingRow` at the method (`Store#contact`). Don't turn
+ that into a nil-returning read and a `row &&` at every caller. Where
+ the rescue would also wrap a transaction, read with `first` and
+ return early instead (`Store#delete_group`).
+- Card content never reaches Sentry: `send_default_pii` is off, and the
+ URL, headers, exception messages, and `capture_message` text must not
+ carry it either.
+- Nothing above `vcard/parser.rb` handles a parse error. A line that
+ will not read comes back with no property, the card is served from
+ its bytes regardless, and unknown properties are kept
+ (RFC 6352 section 6.3.2.2).
+- The one exception is the arrival reports at the PUT
+ (`Web#report_broken_assumptions`, `Web#report_unreadable_lines`):
+ there and not on read, so one bad card does not alert once per page
+ view.
+
+### Store
+
+- What cannot be rebuilt, and the plan behind each, is listed in
+ `Store`'s class comment; everything else is an index
+ `rake index:rebuild` makes again. A write that touches a card lands
+ its change-log entry in the same transaction, and one that touches a
+ group its group-change-log entry, because a sync token or a history
+ silently skips whatever the log missed.
+- An etag is derived from the card, never stored beside it; `Contact`'s
+ constructor is the only way to make one. The etag and diff in
+ `changes` are what that write served, stored rather than derived
+ because nothing can recompute them once the card moves on.
+- Schema changes are new migrations under `db/migrations/`; never edit
+ one that has run anywhere. SQLite writes the timestamps, so Ruby never
+ sets one.
+- Those tables are `STRICT`, so every string column needs `text: true`.
+ Strings reach the store as UTF-8: the one binary input is the request
+ body, relabelled where `write_card` reads it, and the sqlite3 gem and
+ SQLite refuse anything else at the bind or the insert.
+- The app is handed its store (`ProTacts::Web.store`, set by `config.ru`
+ or a test); there is no global. Requiring the app must stay free of
+ side effects, and code reads configuration through `ProTacts.config`,
+ not `ENV`.
+
+### Code
+
+- `vcard` names a `VCard` and nothing else; a card's octets are `bytes`
+ (`Web#write_card`), and a card that needs saying which one keeps its
+ own word (`stored`, `rest`).
+- A wrong name is fixed where it starts, in the change at hand,
+ migration and all. Check what the new word already means in that
+ class before taking it.
+- A class built once and only read back is a function: put it as a
+ `def self.` on the module whose vocabulary it uses (`CardDiff`,
+ `Birthday`, `Admin::Format`). If no name fits, ask whether the thing
+ should exist at all.
+- A screen or button that only restates another page is deleted, and
+ the flow lands on the real thing (docs/DESIGN.md).
+- Comments carry the reasoning, never a restatement of the line below
+ or the test name. Cite reasoning written elsewhere (a `docs/plans/`
+ section, an RFC, the owning method) rather than re-deriving it.
+ Plans are dated records: write a new one rather than editing an old
+ one.
+- String literals are frozen (`RUBYOPT` in `.ramekin/config.kdl`).
+- `config.ru` must stay ASCII-only (a test enforces it), and
+ `rake steep` needs a UTF-8 locale: under a C locale the em dashes in
+ comments are invalid bytes.
+- Workflow actions are pinned by commit with a tag comment naming that
+ commit, never a branch; zizmor flags a mismatch as a code-scanning
+ comment while its job stays green.
+
+### Types
+
+- Types are inline RBS comments, checked by `rake steep`; `sig/` holds
+ only what that syntax cannot express, each file saying why
+ (docs/plans/2026-08-20-type-checking.md). An instance variable
+ declaration must come first in the class body.
+- Steep refuses three shapes Ruby accepts: a bare `[]` or `{}` outside a
+ typed parameter, a literal meant as a tuple, and a local narrowed
+ from nil after it was made. Give the first two their own line with a
+ trailing `#:` type, and return on the nil before making the third.