Type check lib with Steep, from RBS comments in the code
Method types sit above the methods they describe, as `#:` comments, so
they are read and changed with the implementation. `rake steep` runs the
check; it stays out of the default task, which is still the suite.
sig/ carries only what the inline syntax cannot say, and each file there
names the limit that put it there: the five gems that ship no signatures
of their own, the two Data classes whose super class is not a constant,
VCard's module_function, ProTacts' `class << self`, and the request
methods dav_verbs defines by evaluating a string. The gem signatures are
hand-written and cover only the surface this app calls, which keeps the
check free of a signature collection to install and a network fetch to
do it — and neither roda nor kdl is in that collection anyway.
Steep is pinned to 2.1.0.dev.1 because the released 2.0.0 parses Ruby as
3.3, where `it` is a method call rather than the block parameter.
Three things the checker turned up, none of them live bugs:
- A REPORT body that is not XML parses to a document with no root, and
the handler read `doc.root.name` off it. It raises now: the same 500,
with a reason in it.
- Reporting the first unknown key through `reject(...).first` is
nil-shaped to a checker; `find` says it directly.
- `Regexp.last_match(1)` after a `when /\AHTTP_(.+)\z/` is `String?`
even where the match cannot have failed. `delete_prefix` is the same
value with no nil in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mijjo4aMQW3KucpVo8BhxT
diff --git a/AGENTS.md b/AGENTS.md
index 63ab759..498a48e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,6 +11,7 @@ response — the shape of those responses is empirical, not arbitrary.
```bash
rake # Tests (the default task)
+rake steep # Type check lib against the RBS comments in it
rake fixtures # Re-record response fixtures from current behavior
rake dev # Dev server, reloading on change (needs fd and entr)
rake profile:install # Render and stage carddav.mobileconfig for approval
@@ -36,6 +37,7 @@ lib/pro_tacts/
├── profile.rb # carddav.mobileconfig generation
└── debug_logger.rb # Full request/response dumps, off by default
lib/roda/plugins/dav_verbs.rb # PROPFIND and REPORT routing verbs
+sig/ # RBS for what an inline comment cannot say
docs/rfcs/ # Vendored spec texts the code cites
docs/plans/ # Dated design records
test/fixtures/macos-exchange/ # A real client session, replayed
@@ -89,3 +91,15 @@ before regenerating.
current set was found by removing properties until the client broke.
- `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.
+- 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.
+- `rake steep` runs the check with `-EUTF-8` because RBS reads source
+ in the default external encoding: under a C locale the em dashes in
+ these comments are invalid bytes and the parse dies on them.
diff --git a/Gemfile b/Gemfile
index 0c22137..b956271 100644
--- a/Gemfile
+++ b/Gemfile
@@ -17,4 +17,8 @@ group :development do
gem "minitest"
gem "rack-test"
gem "ruby-lsp"
+ # Pinned to a prerelease: released Steep parses Ruby as 3.3, where `it`
+ # is a method call rather than the block parameter, so every block in
+ # lib/ fails to type check. 2.1.0.dev.1 is the first release parsing 3.4.
+ gem "steep", "2.1.0.dev.1"
end
diff --git a/Gemfile.lock b/Gemfile.lock
index af2c003..2b6902d 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -8,24 +8,33 @@ GIT
GEM
remote: https://rubygems.org/
specs:
+ ast (2.4.3)
base64 (0.2.0)
bigdecimal (3.1.9)
concurrent-ruby (1.3.6)
+ csv (3.3.6)
date (3.5.1)
erb (6.0.1)
ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.4-arm64-darwin)
+ ffi (1.17.4-x86_64-linux-gnu)
+ fileutils (1.8.0)
io-console (0.8.2)
irb (1.16.0)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
+ json (2.21.2)
kdl (2.2.0)
base64 (~> 0.2.0)
bigdecimal (~> 3.1.6)
racc (~> 1.5)
simpleidn (~> 0.2.1)
language_server-protocol (3.17.0.6)
+ listen (3.10.0)
+ logger
+ rb-fsevent (~> 0.10, >= 0.10.3)
+ rb-inotify (~> 0.9, >= 0.9.10)
logger (1.7.0)
minitest (6.0.1)
prism (~> 1.5)
@@ -34,6 +43,11 @@ GEM
racc (~> 1.4)
nokogiri (1.19.0-arm64-darwin)
racc (~> 1.4)
+ nokogiri (1.19.0-x86_64-linux-gnu)
+ racc (~> 1.4)
+ parser (3.3.12.0)
+ ast (~> 2.4.1)
+ racc
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
@@ -49,7 +63,11 @@ GEM
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
+ rainbow (3.1.1)
rake (13.3.1)
+ rb-fsevent (0.11.2)
+ rb-inotify (0.11.1)
+ ffi (~> 1.0)
rbs (4.1.3)
logger
prism (>= 1.6.0)
@@ -66,16 +84,41 @@ GEM
language_server-protocol (~> 3.17.0)
prism (>= 1.2, < 2.0)
rbs (>= 3, < 5)
+ securerandom (0.4.1)
sentry-ruby (6.2.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
simpleidn (0.2.3)
+ steep (2.1.0.dev.1)
+ concurrent-ruby (>= 1.1.10)
+ csv (>= 3.0.9)
+ fileutils (>= 1.1.0)
+ json (>= 2.1.0)
+ language_server-protocol (>= 3.17.0.4, < 4.0)
+ listen (~> 3.0)
+ logger (>= 1.3.0)
+ parser (>= 3.2)
+ prism (>= 0.25.0)
+ rainbow (>= 2.2.2, < 4.0)
+ rbs (~> 4.0)
+ securerandom (>= 0.1)
+ strscan (>= 1.0.0)
+ terminal-table (>= 2, < 5)
+ uri (>= 0.12.0)
stringio (3.2.0)
+ strscan (3.1.8)
+ terminal-table (4.0.0)
+ unicode-display_width (>= 1.1.1, < 4)
tsort (0.2.0)
+ unicode-display_width (3.2.0)
+ unicode-emoji (~> 4.1)
+ unicode-emoji (4.2.0)
+ uri (1.1.1)
PLATFORMS
aarch64-linux
arm64-darwin-25
+ x86_64-linux
DEPENDENCIES
hegeltest!
@@ -91,25 +134,34 @@ DEPENDENCIES
roda
ruby-lsp
sentry-ruby
+ steep (= 2.1.0.dev.1)
CHECKSUMS
+ ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
base64 (0.2.0) sha256=0f25e9b21a02a0cc0cea8ef92b2041035d39350946e8789c562b2d1a3da01507
bigdecimal (3.1.9) sha256=2ffc742031521ad69c2dfc815a98e426a230a3d22aeac1995826a75dabfad8cc
concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
+ csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5
ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b
+ ffi (1.17.4-x86_64-linux-gnu)
+ fileutils (1.8.0) sha256=8c6b1df54e2540bdb2f39258f08af78853aa70bad52b4d394bbc6424593c6e02
hegeltest (0.0.0)
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
irb (1.16.0) sha256=2abe56c9ac947cdcb2f150572904ba798c1e93c890c256f8429981a7675b0806
+ json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
kdl (2.2.0) sha256=46e56c6686dbb681625a0d6c6193e4b732d6627e8758f8d5e9295309ded958e6
language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
+ listen (3.10.0) sha256=c6e182db62143aeccc2e1960033bebe7445309c7272061979bb098d03760c9d2
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
nokogiri (1.19.0-aarch64-linux-gnu) sha256=11a97ecc3c0e7e5edcf395720b10860ef493b768f6aa80c539573530bc933767
nokogiri (1.19.0-arm64-darwin) sha256=0811dfd936d5f6dd3f6d32ef790568bf29b2b7bead9ba68866847b33c9cf5810
+ nokogiri (1.19.0-x86_64-linux-gnu)
+ parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828
pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
prism (1.8.0) sha256=84453a16ef5530ea62c5f03ec16b52a459575ad4e7b9c2b360fd8ce2c39c1254
@@ -119,16 +171,26 @@ CHECKSUMS
rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
+ rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
+ rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe
+ rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e
rbs (4.1.3) sha256=0c4474a9751cdc14364bfad0b3e53678323bbdc2c31683b0445932867dbab8c4
rdoc (7.0.3) sha256=dfe3d0981d19b7bba71d9dbaeb57c9f4e3a7a4103162148a559c4fc687ea81f9
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
roda (3.100.0) sha256=35d36f43c68d2bd1974dc77ade8c873558265a191018e1ada88ca662f1fa8e62
ruby-lsp (0.26.10) sha256=e67284af94423531f6b9a583350596421b5a6a4dd93083f1c2ba03da7c23bbed
+ securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
sentry-ruby (6.2.0) sha256=d7b8a358a3a0d0536bd194b19cc282c85f295b6d242731e9b4a934ccdb71ac17
simpleidn (0.2.3) sha256=08ce96f03fa1605286be22651ba0fc9c0b2d6272c9b27a260bc88be05b0d2c29
+ steep (2.1.0.dev.1) sha256=af0b17c8d45a5e76d66e5218512db21e13e6a173e0763a109c2af97511cd5215
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
+ strscan (3.1.8) sha256=aae2db611a225559f21ffbb71765c9a4e60fd262534a9ea84f4f11c7f32f679e
+ terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
+ unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
+ unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
+ uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
BUNDLED WITH
4.0.3
diff --git a/Rakefile b/Rakefile
index 0ff7be3..af045d4 100644
--- a/Rakefile
+++ b/Rakefile
@@ -25,6 +25,15 @@ task :fixtures do
ExchangeFixtures.record_responses(ProTacts::Web)
end
+desc "Type check lib against the RBS comments in it"
+task :steep do
+ # RBS reads source with the default external encoding, so under a C
+ # locale every em dash in a comment is an invalid byte rather than a
+ # character and the check dies parsing them. The same footgun the
+ # ASCII-only rule in config.ru exists for.
+ sh({"RUBYOPT" => "#{ENV.fetch('RUBYOPT', '')} -EUTF-8"}, "steep", "check")
+end
+
desc "Render the macOS configuration profile (carddav.mobileconfig)"
task profile: "carddav.mobileconfig"
diff --git a/Steepfile b/Steepfile
new file mode 100644
index 0000000..d4ca4ea
--- /dev/null
+++ b/Steepfile
@@ -0,0 +1,14 @@
+
+# Type checking for lib/. Signatures live in the code as RBS comments
+# (`#:` before a method, `# @rbs` for everything else), so the type and
+# the implementation are read and changed together.
+#
+# sig/ holds only what inline comments cannot express: the gems we call
+# into, which ship no signatures of their own, and the two Data classes
+# (see sig/pro_tacts/). Both are documented where they are defined.
+target :lib do
+ check "lib", inline: true
+ signature "sig"
+
+ library "digest", "fileutils", "logger", "pathname"
+end
diff --git a/docs/plans/2026-08-20-type-checking.md b/docs/plans/2026-08-20-type-checking.md
new file mode 100644
index 0000000..2cf6e8d
--- /dev/null
+++ b/docs/plans/2026-08-20-type-checking.md
@@ -0,0 +1,100 @@
+# Type checking with inline RBS and Steep
+
+2026-08-20. Types for `lib/`, written as comments in the code rather than
+as a parallel signature tree, and checked by `rake steep`.
+
+## The shape
+
+A method's type sits above it, in the comment a reader is already
+looking at:
+
+```ruby
+#: (KDL::Document document, uid: String) -> String
+def render(document, uid:)
+```
+
+`# @rbs` carries what a method type cannot — instance variables, and the
+`skip` that hands a declaration over to `sig/`. A trailing `#:` gives a
+constant its type or asserts one the checker cannot infer.
+
+`rake steep` runs the check. It is not part of the default task: the
+suite stays the thing `rake` runs, and this is a second opinion, not a
+gate the tests wait on. Moving it under `default` is a one-line change
+if that turns out to be the wrong split.
+
+## Why comments rather than a sig tree
+
+A parallel `sig/**/*.rbs` is a second copy of every method in the
+project, and the two drift the moment anything is renamed. Everything
+this project keeps — the RFC citations, the reasons a property is in a
+response — is already written next to the code it explains, and types
+are the same kind of note.
+
+## What sig/ is still for
+
+Five things the inline syntax cannot say. Each file there names the one
+that put it there:
+
+| File | Limit |
+|---|---|
+| `sig/gems/*.rbs` | kdl, nokogiri, rack, roda and sentry-ruby ship no signatures |
+| `sig/pro_tacts/contact.rbs`, `addressbook.rbs` | `class X < Data.define(...)` has no constant super class to read |
+| `sig/pro_tacts/vcard.rbs` | `module_function` has no inline spelling; `extend VCard` says it |
+| `sig/pro_tacts.rbs` | `class << self` is not read |
+| `sig/roda/plugins/dav_verbs.rbs` | methods defined by `class_eval` on a string |
+
+The Data classes are the only real loss: their whole signature lives in
+`sig/`, away from the code, because `@rbs skip` takes the method types
+with it. Their bodies are still checked against it.
+
+## Why the gem signatures are hand-written
+
+gem_rbs_collection would mean `rbs collection install`, a second
+lockfile, and a network fetch before the checker can run — and it
+carries neither roda nor kdl, which are the two that matter here. Each
+stub is a few dozen lines covering only what this app calls. Anything
+called that is missing from one is a type error rather than a silent
+`untyped`, which is the point.
+
+Two of them narrow reality on purpose, and say so where they do it:
+Sentry events stay `untyped`, because `SentryScrubber` feature-detects
+its way through whatever `before_send` hands it, and Roda's matchers
+stay `untyped`, because the vocabulary is open and the block arguments
+depend on which matcher matched. `r.get String do |filename|` yields an
+untyped filename.
+
+## Steep is pinned to a prerelease
+
+`steep 2.0.0` parses Ruby as 3.3, where `it` is a method call rather
+than the block parameter, so every block in `lib/` fails. `2.1.0.dev.1`
+is the first release parsing 3.4. Unpin it when 2.1.0 ships.
+
+## Encoding
+
+RBS reads source files in the default external encoding, so under a C
+locale every em dash in these comments is an invalid byte and the check
+dies inside the parser. `rake steep` sets `-EUTF-8` for the run. This is
+the same footgun the ASCII-only rule in `config.ru` exists for.
+
+## What the checker found
+
+- `Nokogiri::XML` returns a document with no root when the body is not
+ XML, and the REPORT handler read `doc.root.name` straight out. It
+ raises now, which is the same 500 with a legible reason.
+- Two spots reported the first unknown key with `reject(...).first`,
+ which is nil-shaped to a checker. `find` says the same thing directly.
+- `Regexp.last_match(1)` after a `when /\AHTTP_(.+)\z/` is `String?`
+ even where the match cannot have failed; `key.delete_prefix("HTTP_")`
+ is the same value with no nil in it.
+
+None of these were live bugs. The nil-root one was the closest.
+
+## What is not checked
+
+`test/`, the `Rakefile` and `config.ru`. Tests are already the check on
+themselves, and typing them means signatures for minitest and rack-test
+before anything is learned.
+
+One thing to know about the boundary: a file Steep cannot parse is
+skipped without a word, so a syntax error reads as a clean check. `rake`
+is what catches that.
diff --git a/lib/pro_tacts/addressbook.rb b/lib/pro_tacts/addressbook.rb
index 51e11be..dba85a1 100644
--- a/lib/pro_tacts/addressbook.rb
+++ b/lib/pro_tacts/addressbook.rb
@@ -8,6 +8,10 @@ module ProTacts
# The ctag changes when any card is added, removed, or changed and
# nothing else, so a client comparing two of them learns whether a
# resync is needed — never what changed.
+ #
+ # The signature lives in sig/pro_tacts/addressbook.rbs, for the same
+ # reason Contact's does.
+ # @rbs skip
class Addressbook < Data.define(:contacts)
def self.load(directory)
new(contacts: Contact.all(directory))
diff --git a/lib/pro_tacts/config.rb b/lib/pro_tacts/config.rb
index ad22d55..bcda6d9 100644
--- a/lib/pro_tacts/config.rb
+++ b/lib/pro_tacts/config.rb
@@ -6,8 +6,11 @@ module ProTacts
# Nothing else in the app should read ENV directly; add a method here and
# read it through ProTacts.config instead.
class Config
- TRUTHY = /\A(1|true|yes)\z/i
+ # @rbs @env: Hash[String, String]
+ TRUTHY = /\A(1|true|yes)\z/i #: Regexp
+
+ #: (?Hash[String, String] env) -> void
def initialize(env = ENV)
@env = env
end
@@ -15,12 +18,14 @@ module ProTacts
# Sentry DSN; nil when unset. A nil DSN is passed straight to
# Sentry.init, which leaves the client inert — capture_message and
# the rack middleware become no-ops.
+ #: () -> String?
def sentry_dsn
@env.fetch("SENTRY_DSN", nil)
end
# Whether to dump full request/response exchanges to the log. Off by
# default because it logs contact data. See ProTacts::DebugLogger.
+ #: () -> bool
def debug?
value = @env.fetch("PRO_TACTS_DEBUG", nil)
!value.nil? && value.match?(TRUTHY)
@@ -28,6 +33,7 @@ module ProTacts
# Root data directory: holds the contacts directory and, later, the
# database. Overridable with PRO_TACTS_DATA_DIR.
+ #: () -> Pathname
def data_dir
Pathname.new(@env.fetch("PRO_TACTS_DATA_DIR", "data"))
end
@@ -35,6 +41,7 @@ module ProTacts
# Contacts live at data/contacts, one KDL file per contact; the
# filename is the contact ID. See
# docs/plans/2026-01-12-carddav-architecture.md.
+ #: () -> Pathname
def contacts_dir
data_dir / "contacts"
end
@@ -42,12 +49,14 @@ module ProTacts
# Where requests the app could not answer are kept, one directory per
# distinct request. Under log/ because it holds request data and is not
# meant to be committed. See ProTacts::UnhandledRequests.
+ #: () -> Pathname
def unhandled_dir
Pathname.new(@env.fetch("PRO_TACTS_UNHANDLED_DIR", "log/unhandled"))
end
# Where the debug logger writes. A path, overridable with
# PRO_TACTS_DEBUG_LOG; "stderr" keeps it on the process's stderr.
+ #: () -> String
def debug_log_path
@env.fetch("PRO_TACTS_DEBUG_LOG", "log/debug.log")
end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 29e0c1a..3470470 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -15,6 +15,10 @@ module ProTacts
# changes the bytes without changing the card. It is stored in the
# entity-tag's quoted form (RFC 7232 section 2.3), which is what both
# the ETag header and getetag properties carry.
+ #
+ # The signature lives in sig/pro_tacts/contact.rbs: a Data class has
+ # no constant super class for the inline syntax to read.
+ # @rbs skip
class Contact < Data.define(:id, :vcard, :etag)
# Ids end up in paths and arrive from client-supplied hrefs, so a
# filename outside this charset cannot be served.
diff --git a/lib/pro_tacts/debug_logger.rb b/lib/pro_tacts/debug_logger.rb
index b278ada..b1c2d6e 100644
--- a/lib/pro_tacts/debug_logger.rb
+++ b/lib/pro_tacts/debug_logger.rb
@@ -14,15 +14,19 @@ module ProTacts
# fictional, so the debug path stays verbose while the normal one-line path
# (Roda's common_logger) can be narrowed later without losing this.
class DebugLogger
+ # @rbs @app: Rack::_App
+ # @rbs @logger: Logger
+
# Builds the Logger the middleware writes to: appended and unbuffered
# (Logger syncs its own device), one timestamped line per dump. path
# "stderr" writes to the process's stderr.
+ #: (String | Pathname path) -> Logger
def self.open_log(path)
target = if path.to_s == "stderr"
$stderr
else
FileUtils.mkdir_p(Pathname.new(path).dirname)
- path
+ path.to_s
end
Logger.new(target).tap do |logger|
@@ -33,11 +37,13 @@ module ProTacts
end
end
+ #: (Rack::_App app, logger: Logger) -> void
def initialize(app, logger:)
@app = app
@logger = logger
end
+ #: (Rack::env env) -> Rack::response
def call(env)
log_request(env)
status, headers, body = @app.call(env)
@@ -47,6 +53,7 @@ module ProTacts
private
+ #: (Rack::env env) -> void
def log_request(env)
write(">>", "#{env.fetch('REQUEST_METHOD')} #{full_path(env)} #{env.fetch('SERVER_PROTOCOL')}")
each_header(env) do |name, value|
@@ -56,31 +63,39 @@ module ProTacts
write(">>", body) unless body.empty?
end
+ # Returns the body parts it consumed, for the caller to send on in
+ # place of the body it read.
+ #: (Integer status, Rack::headers headers, Rack::_Body body) -> Array[String]
def log_response(status, headers, body)
write("<<", "#{status}#{reason(status)}")
headers.each do |name, value|
write("<<", "#{name}: #{value}")
end
- parts = []
+ parts = [] #: Array[String]
body.each do |part|
parts << part
end
- body.close if body.respond_to?(:close)
+ # A body holding a resource closes it. No signature can say
+ # "close if you have one", so the cast carries what respond_to?
+ # has already established.
+ (_ = body).close if body.respond_to?(:close)
write("<<", parts.join) unless parts.join.empty?
parts
end
+ #: (Rack::env env) -> String
def full_path(env)
path = env["PATH_INFO"].to_s
query = env["QUERY_STRING"].to_s
query.empty? ? path : "#{path}?#{query}"
end
+ #: (Rack::env env) { (String, untyped) -> void } -> void
def each_header(env)
env.each do |key, value|
case key
when /\AHTTP_(.+)\z/
- yield header_name(Regexp.last_match(1)), value
+ yield header_name(key.delete_prefix("HTTP_")), value
when "CONTENT_TYPE"
yield "Content-Type", value
when "CONTENT_LENGTH"
@@ -89,10 +104,12 @@ module ProTacts
end
end
+ #: (String name) -> String
def header_name(name)
name.split("_").map(&:capitalize).join("-")
end
+ #: (Rack::env env) -> String
def read_request_body(env)
input = env["rack.input"]
return "" if input.nil?
@@ -101,11 +118,13 @@ module ProTacts
body
end
+ #: (Integer status) -> String
def reason(status)
phrase = Rack::Utils::HTTP_STATUS_CODES[status]
phrase ? " #{phrase}" : ""
end
+ #: (String prefix, String text) -> void
def write(prefix, text)
text.to_s.lines(chomp: true).each do |line|
@logger.debug("#{prefix} #{line}")
diff --git a/lib/pro_tacts/profile.rb b/lib/pro_tacts/profile.rb
index 9defea3..a06af58 100644
--- a/lib/pro_tacts/profile.rb
+++ b/lib/pro_tacts/profile.rb
@@ -21,6 +21,7 @@ module ProTacts
# injects (see ProTacts::TailscaleAuth). They stay in the template
# because the account form expects the fields; dropping them is
# untested.
+ #: (hostname: String) -> String
def self.render(hostname:)
identifier = "#{IDENTIFIER_PREFIX}-#{unique_hex}"
@@ -38,10 +39,14 @@ module ProTacts
# Scans for the prefix anywhere in the output rather than assuming a
# key-value layout, since the listing format has changed across macOS
# versions (key-value today, table under later releases).
+ #: (String list_output) -> Array[String]
def self.installed_identifiers(list_output)
- list_output.scan(/(?<![\w.-])#{Regexp.escape(IDENTIFIER_PREFIX)}-[\w.-]+/).uniq
+ # A pattern with no groups scans to whole matches, which is
+ # narrower than the signature of String#scan can say.
+ list_output.scan(/(?<![\w.-])#{Regexp.escape(IDENTIFIER_PREFIX)}-[\w.-]+/).uniq #: Array[String]
end
+ #: () -> String
def self.template
<<~XML
<?xml version="1.0" encoding="UTF-8"?>
@@ -99,14 +104,17 @@ module ProTacts
# CardDAVPrincipalURL is omitted on purpose: no Server Path, matching
# the bare-hostname setup the working session used.
+ #: (String text) -> String
def self.escape(text)
text.gsub("&", "&").gsub("<", "<").gsub(">", ">")
end
+ #: () -> String
def self.unique_hex
"#{Time.now.utc.strftime('%Y%m%d%H%M%S%L')}#{rand(1 << 16).to_s(16)}"
end
+ #: () -> String
def self.uuid
[8, 4, 4, 4, 12].map { |n| Array.new(n) { HEX[rand(16)] }.join }.join("-")
end
diff --git a/lib/pro_tacts/sentry_scrubber.rb b/lib/pro_tacts/sentry_scrubber.rb
index b2961e7..66ea3db 100644
--- a/lib/pro_tacts/sentry_scrubber.rb
+++ b/lib/pro_tacts/sentry_scrubber.rb
@@ -16,10 +16,11 @@ module ProTacts
# lazy quantifier, whose stopping point depends on alternation order.
VCARD = /BEGIN:VCARD(?~END:VCARD)(?:END:VCARD|\z)/mi
VCARD_CONTENT_TYPE = %r{\Atext/vcard}i
- REDACTED = "[vcard redacted]".freeze
+ REDACTED = "[vcard redacted]".freeze #: String
# Sentry discards the event unless a Sentry::ErrorEvent comes back, so
# this returns the event it was handed either way.
+ #: (untyped event, ?untyped hint) -> untyped
def self.call(event, _hint = nil)
request = event.request if event.respond_to?(:request)
return event unless request
@@ -36,6 +37,7 @@ module ProTacts
# A card PUT is a card whether or not it parses, so the content type is
# enough on its own.
+ #: (untyped request) -> bool
def self.vcard_body?(request)
headers = request.headers
return false unless headers.respond_to?(:[])
@@ -44,6 +46,7 @@ module ProTacts
end
# Form bodies arrive as a params hash rather than a string.
+ #: (untyped data) -> untyped
def self.redact(data)
case data
when String then data.gsub(VCARD, REDACTED)
diff --git a/lib/pro_tacts/tailscale_auth.rb b/lib/pro_tacts/tailscale_auth.rb
index 603cfbe..13b9bf8 100644
--- a/lib/pro_tacts/tailscale_auth.rb
+++ b/lib/pro_tacts/tailscale_auth.rb
@@ -14,6 +14,8 @@ module ProTacts
# Any tailnet identity is accepted. Getting onto the tailnet is the access
# control; the address book has no per-user view to protect.
class TailscaleAuth
+ # @rbs @app: Rack::_App
+
LOGIN_HEADER = "HTTP_TAILSCALE_USER_LOGIN"
NAME_HEADER = "HTTP_TAILSCALE_USER_NAME"
@@ -21,10 +23,12 @@ module ProTacts
# to know who is asking.
IDENTITY = "pro_tacts.user"
+ #: (Rack::_App app) -> void
def initialize(app)
@app = app
end
+ #: (Rack::env env) -> Rack::response
def call(env)
login = env[LOGIN_HEADER].to_s.strip
@@ -38,6 +42,7 @@ module ProTacts
private
+ #: () -> Rack::response
def forbidden
body = "Forbidden: no Tailscale identity on this request.\n"
[403, { "Content-Type" => "text/plain", "Content-Length" => body.bytesize.to_s }, [body]]
diff --git a/lib/pro_tacts/unhandled_requests.rb b/lib/pro_tacts/unhandled_requests.rb
index 1cc24b1..c556b90 100644
--- a/lib/pro_tacts/unhandled_requests.rb
+++ b/lib/pro_tacts/unhandled_requests.rb
@@ -14,25 +14,33 @@ module ProTacts
# This is the local counterpart to the Sentry reporting in config.ru, which
# no longer sends request bodies.
class UnhandledRequests
+ # @rbs @app: Rack::_App
+ # @rbs @directory: Pathname
+
# 404 is the missing-functionality signal: a client asked for something
# this server does not route. 5xx is kept because Sentry now reports
# those without a body, and a crash is hard to read without one.
+ #: (Integer status) -> bool
def self.capture?(status)
status == 404 || status >= 500
end
+ #: (Rack::_App app, directory: Pathname | String) -> void
def initialize(app, directory:)
@app = app
@directory = Pathname.new(directory)
end
+ #: (Rack::env env) -> Rack::response
def call(env)
status, headers, body = @app.call(env)
return [status, headers, body] unless self.class.capture?(status)
- parts = []
+ parts = [] #: Array[String]
body.each { parts << it }
- body.close if body.respond_to?(:close)
+ # See DebugLogger#log_response: respond_to? cannot narrow an
+ # interface, so the cast stands for the check beside it.
+ (_ = body).close if body.respond_to?(:close)
capture(env, status, headers, parts.join)
@@ -41,6 +49,7 @@ module ProTacts
private
+ #: (Rack::env env, Integer status, Rack::headers headers, String body) -> void
def capture(env, status, headers, body)
target = @directory / name_for(env)
@@ -57,52 +66,65 @@ module ProTacts
warn "pro-tacts: could not record unhandled request: #{e.message}"
end
+ #: (Rack::env env) -> String
def name_for(env)
slug = env["PATH_INFO"].to_s.gsub(%r{[^\w]+}, "-").delete_prefix("-").delete_suffix("-")
slug = "root" if slug.empty?
"#{env['REQUEST_METHOD'].to_s.downcase}-#{slug}-#{digest(env)}"
end
+ #: (Rack::env env) -> String
def digest(env)
- Digest::SHA256.hexdigest([env["REQUEST_METHOD"], env["PATH_INFO"], request_body(env)].join("\n"))[0, 8]
+ # Slicing a hexdigest cannot come up short, which String#[] has
+ # no way to promise.
+ Digest::SHA256.hexdigest([env["REQUEST_METHOD"], env["PATH_INFO"], request_body(env)].join("\n"))[0, 8] #: String
end
+ #: (Rack::env env) -> String
def render_request(env)
lines = ["#{env['REQUEST_METHOD']} #{full_path(env)} #{env.fetch('SERVER_PROTOCOL', 'HTTP/1.1')}"]
lines += each_header(env).map { |name, value| "#{name}: #{value}" }
message(lines, request_body(env))
end
+ #: (Integer status, Rack::headers headers, String body) -> String
def render_response(status, headers, body)
message([status.to_s] + headers.map { |name, value| "#{name}: #{value}" }, body)
end
+ #: (Array[String] lines, String body) -> String
def message(lines, body)
head = lines.join("\n") + "\n\n"
body = body.to_s.chomp
body.empty? ? head : "#{head}#{body}\n"
end
+ #: (Rack::env env) -> String
def full_path(env)
path = env["PATH_INFO"].to_s
query = env["QUERY_STRING"].to_s
query.empty? ? path : "#{path}?#{query}"
end
+ #: (Rack::env env) -> Array[[String, untyped]]
def each_header(env)
env.filter_map { |key, value|
case key
- when /\AHTTP_(.+)\z/ then [header_name(Regexp.last_match(1)), value]
+ when /\AHTTP_(.+)\z/ then [header_name(key.delete_prefix("HTTP_")), value]
when "CONTENT_TYPE" then ["Content-Type", value]
when "CONTENT_LENGTH" then ["Content-Length", value]
end
- }.sort
+ # The two-element arrays are pairs, which filter_map has no way
+ # to say.
+ }.sort #: Array[[String, untyped]]
end
+ #: (String name) -> String
def header_name(name)
name.split("_").map(&:capitalize).join("-")
end
+ #: (Rack::env env) -> String
def request_body(env)
input = env["rack.input"]
return "" if input.nil?
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index 4b6ce0f..785f67f 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -14,9 +14,9 @@ module ProTacts
# character.
LINE_LIMIT = 75
- NAME_COMPONENTS = %w[family given additional prefix suffix].freeze
- ADDRESS_PARTS = %w[street city state zip country].freeze
- CONTACT_FIELDS = %w[name phone email address].freeze
+ NAME_COMPONENTS = %w[family given additional prefix suffix].freeze #: Array[String]
+ ADDRESS_PARTS = %w[street city state zip country].freeze #: Array[String]
+ CONTACT_FIELDS = %w[name phone email address].freeze #: Array[String]
# Properties each node accepts; anything else is a typo silently
# dropping data, so it raises.
@@ -24,26 +24,28 @@ module ProTacts
"phone" => %w[type],
"email" => %w[type],
"address" => %w[type],
- }.freeze
+ }.freeze #: Hash[String, Array[String]]
TEXT_ESCAPES = {
"\\" => "\\\\",
";" => "\\;",
"," => "\\,",
"\n" => "\\n",
- }.freeze
+ }.freeze #: Hash[String, String]
module_function
+ #: (KDL::Document document, uid: String) -> String
def render(document, uid:)
nodes = document.nodes
validate_children(nodes, CONTACT_FIELDS, "contact")
names = nodes.select { it.name == "name" }
- raise ArgumentError, "contact requires a name" if names.empty?
+ name = names.first
+ raise ArgumentError, "contact requires a name" if name.nil?
raise ArgumentError, "contact takes a single name" if names.length > 1
- n, fn = name_fields(names.first)
+ n, fn = name_fields(name)
lines = [
"BEGIN:VCARD",
@@ -68,6 +70,7 @@ module ProTacts
# suffix; empty parts skipped). Providing both is an error: they are
# two spellings of the same truth, and silently preferring one would
# hide the disagreement. Returns [N components, FN].
+ #: (KDL::Node name) -> [Array[String], String]
def name_fields(name)
validate_children(name, NAME_COMPONENTS, "name")
validate_properties(name)
@@ -100,6 +103,7 @@ module ProTacts
end
end
+ #: (Array[KDL::Node] nodes, String kdl_name, String vcard_name) -> Array[String]
def typed_property_lines(nodes, kdl_name, vcard_name)
nodes.select { it.name == kdl_name }.map { |node|
validate_properties(node)
@@ -112,13 +116,18 @@ module ProTacts
# ADR's seven components in order: pobox, extended address, street,
# locality, region, postal code, country. The first two have no KDL
# counterpart and stay empty.
+ #: (Array[KDL::Node] nodes) -> Array[String]
def address_lines(nodes)
nodes.select { it.name == "address" }.map { |node|
validate_children(node, ADDRESS_PARTS, "address")
validate_properties(node)
parts = node.children
.select { ADDRESS_PARTS.include?(it.name) }
- .to_h { [it.name, string_argument(it)] }
+ .to_h do |part|
+ # A two-element literal is an Array until something says
+ # otherwise, and to_h takes pairs.
+ [part.name, string_argument(part)] #: [String, String]
+ end
components = ["", "", *ADDRESS_PARTS.map { parts.fetch(it, "") }]
type = node.properties["type"]&.value
@@ -131,11 +140,13 @@ module ProTacts
# sub-component separator (RFC 2426 section 2.4.2); CRLF and CR are
# normalized to the `\n` escape because a raw line break would end
# the property line.
+ #: (String text) -> String
def escape(text)
text.gsub(/\r\n|\r/, "\n").gsub(/[\\;,\n]/) { TEXT_ESCAPES.fetch(it) }
end
# Escapes each component, then joins with the component separator.
+ #: (Array[String] values) -> String
def components(values)
values.map { escape(it) }.join(";")
end
@@ -144,6 +155,7 @@ module ProTacts
# octets, each continuation starting with a single space (RFC 2426
# section 2.6). The walk is character-wise so a multibyte character
# is never split mid-sequence.
+ #: (String line) -> String
def fold(line)
return line if line.bytesize <= LINE_LIMIT
@@ -160,6 +172,7 @@ module ProTacts
folded
end
+ #: (KDL::Node node) -> String
def string_argument(node)
argument = node.arguments.first
raise ArgumentError, "#{node.name} requires a string argument" if argument.nil?
@@ -167,13 +180,16 @@ module ProTacts
argument.value.to_s
end
+ # Takes a node list or a single node, whose children it walks.
+ #: (Array[KDL::Node] | KDL::Node nodes, Array[String] known, String context) -> void
def validate_children(nodes, known, context)
- unknown = nodes.reject { known.include?(it.name) }
- return if unknown.empty?
+ unknown = nodes.find { !known.include?(it.name) }
+ return if unknown.nil?
- raise ArgumentError, "unknown key in #{context}: #{unknown.first.name}"
+ raise ArgumentError, "unknown key in #{context}: #{unknown.name}"
end
+ #: (KDL::Node node) -> void
def validate_properties(node)
allowed = ALLOWED_PROPERTIES.fetch(node.name, [])
unknown = node.properties.keys - allowed
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 73604c8..7b3aa4c 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -14,6 +14,8 @@ require "roda/plugins/dav_verbs"
module ProTacts
class Web < Roda
+ # @rbs @addressbook: Addressbook?
+
# RewindableInput allows us to read the request body for Sentry logging
# and then rewind it so the application can still access it.
use Rack::RewindableInput::Middleware
@@ -212,7 +214,13 @@ module ProTacts
doc = Nokogiri::XML(body)
doc.remove_namespaces!
- if doc.root.name == "sync-collection"
+ # A body that is not XML parses to a document with no root.
+ # There is no report to dispatch on, so raise and let it 500
+ # rather than answer as though nothing was asked for.
+ root = doc.root
+ raise ArgumentError, "REPORT body is not XML" if root.nil?
+
+ if root.name == "sync-collection"
# DAV:sync-collection (RFC 6578 section 3.2). The warm-sync ask
# is etag-only; a changed etag sends the client back through
# multiget, so no address-data here.
@@ -272,15 +280,18 @@ module ProTacts
# one — with the directory coming from config. Real etags and ctags
# make an mtime-keyed cache possible, but re-parsing a family address
# book per request is cheap and can never serve a stale change tag.
+ #: () -> Addressbook
def addressbook
@addressbook ||= Addressbook.load(ProTacts.config.contacts_dir)
end
+ #: (String id) -> String
def contact_href(id)
"/dav/addressbook/#{id}.vcf"
end
# DAV:getetag (RFC 4918 section 15.6).
+ #: (Contact contact) -> String
def etag_response(contact)
<<~XML
<d:response>
@@ -295,6 +306,7 @@ module ProTacts
XML
end
+ #: (Contact contact) -> String
def card_response(contact)
<<~XML
<d:response>
@@ -312,6 +324,7 @@ module ProTacts
# An href with no match is reported as a 404 inside the 207 rather than
# failing the request (RFC 6352 section 8.7).
+ #: (String requested) -> String
def missing_response(requested)
<<~XML
<d:response>
@@ -323,6 +336,7 @@ module ProTacts
# Text nodes in XML built by interpolation; hrefs and vCard content
# can all contain &, <, or >.
+ #: (String text) -> String
def xml_escape(text)
text.gsub(/[&<>]/, "&" => "&", "<" => "<", ">" => ">")
end
diff --git a/sig/gems/kdl.rbs b/sig/gems/kdl.rbs
new file mode 100644
index 0000000..0a43eca
--- /dev/null
+++ b/sig/gems/kdl.rbs
@@ -0,0 +1,36 @@
+# The kdl gem ships no signatures, so this describes the parts pro-tacts
+# calls: a document is nodes, a node is a name with arguments, properties
+# and children, and every leaf value is wrapped in a KDL::Value.
+#
+# Written against kdl 2.2.0 (lib/kdl/document.rb, node.rb, value.rb).
+module KDL
+ # KDL 2.0 scalars: strings, numbers, booleans, and null.
+ type value = String | Integer | Float | bool | nil
+
+ def self.parse: (String input, **untyped options) -> Document
+
+ class Document
+ include Enumerable[Node]
+
+ attr_accessor nodes: Array[Node]
+
+ def each: () { (Node) -> void } -> void
+ end
+
+ class Node
+ include Enumerable[Node]
+
+ attr_accessor name: String
+ attr_accessor arguments: Array[Value]
+ attr_accessor properties: Hash[String, Value]
+ attr_accessor children: Array[Node]
+
+ # Yields the children, which is what makes a node interchangeable
+ # with a node list wherever pro-tacts walks one.
+ def each: () { (Node) -> void } -> void
+ end
+
+ class Value
+ attr_reader value: KDL::value
+ end
+end
diff --git a/sig/gems/nokogiri.rbs b/sig/gems/nokogiri.rbs
new file mode 100644
index 0000000..b7798cf
--- /dev/null
+++ b/sig/gems/nokogiri.rbs
@@ -0,0 +1,28 @@
+# The nokogiri surface pro-tacts uses to read REPORT bodies: parse, drop
+# the namespaces, and pull elements out by xpath.
+#
+# `root` is declared non-nil because Nokogiri::XML only returns a
+# document without one for input that is not XML at all, and web.rb
+# raises on that case explicitly rather than reading a nil root.
+module Nokogiri
+ def self.XML: (String string_or_io) -> Nokogiri::XML::Document
+
+ module XML
+ class Node
+ def name: () -> String
+ def text: () -> String
+ def xpath: (String) -> NodeSet
+ end
+
+ class Document < Node
+ def root: () -> Node?
+ def remove_namespaces!: () -> Document
+ end
+
+ class NodeSet
+ include Enumerable[Node]
+
+ def each: () { (Node) -> void } -> void
+ end
+ end
+end
diff --git a/sig/gems/rack.rbs b/sig/gems/rack.rbs
new file mode 100644
index 0000000..b37c5ab
--- /dev/null
+++ b/sig/gems/rack.rbs
@@ -0,0 +1,45 @@
+# Rack ships no signatures. This names the vocabulary the middleware in
+# lib/pro_tacts speaks — the environment, the response triple, and an
+# app to delegate to — plus the two constants used directly: the status
+# phrases the debug logger prints, and the middleware config.ru's
+# comment explains (web.rb mounts it so a body can be read twice).
+module Rack
+ # The CGI-ish hash a middleware is called with. Values stay untyped:
+ # anything upstream may put anything in it, which is why the code
+ # reads it through fetch and to_s.
+ type env = Hash[String, untyped]
+
+ type headers = Hash[String, String]
+
+ # What every middleware returns and what every middleware in this app
+ # passes along, rebuilt from parts once the body has been consumed.
+ type response = [Integer, headers, _Body]
+
+ # A response body: enumerable once, and closeable if it holds a
+ # resource — which only respond_to? can tell you, so it is here and
+ # close is not.
+ interface _Body
+ def each: () { (String) -> void } -> void
+ def respond_to?: (Symbol name) -> bool
+ end
+
+ # The request body, as env["rack.input"] carries it.
+ interface _Input
+ def read: () -> String
+ def rewind: () -> void
+ end
+
+ # The app a middleware wraps.
+ interface _App
+ def call: (env) -> response
+ end
+
+ module Utils
+ HTTP_STATUS_CODES: Hash[Integer, String]
+ end
+
+ class RewindableInput
+ class Middleware
+ end
+ end
+end
diff --git a/sig/gems/roda.rbs b/sig/gems/roda.rbs
new file mode 100644
index 0000000..4748381
--- /dev/null
+++ b/sig/gems/roda.rbs
@@ -0,0 +1,40 @@
+# Roda ships no signatures. This is the routing surface web.rb uses and
+# no more of it: the class methods that build the app, the request
+# methods that match, and the two response setters.
+#
+# Matchers stay untyped on purpose. Roda's matcher vocabulary — strings,
+# classes, regexps, symbols, hashes, and arrays of those — is open, and
+# the block arguments a match yields depend on which matcher matched.
+class Roda
+ def self.plugin: (Symbol | Module plugin, *untyped args) ?{ () [self: instance] -> untyped } -> void
+
+ def self.use: (untyped middleware, **untyped options) -> void
+
+ # The block is the router, run against a fresh instance per request.
+ def self.route: () { (RodaRequest) [self: instance] -> untyped } -> void
+
+ def request: () -> RodaRequest
+ def response: () -> RodaResponse
+
+ class RodaRequest
+ def env: () -> Rack::env
+ def body: () -> Rack::_Input
+
+ def is: (*untyped matchers) { (*untyped) -> untyped } -> void
+ def on: (*untyped matchers) { (*untyped) -> untyped } -> void
+ def get: (*untyped matchers) ?{ (*untyped) -> untyped } -> void
+ def options: (*untyped matchers) ?{ (*untyped) -> untyped } -> void
+
+ # Halts the request, so nothing after it in a route block runs.
+ def redirect: (String path, ?Integer status) -> bot
+ end
+
+ class RodaResponse
+ def []=: (String header, String value) -> void
+ def status=: (Integer status) -> void
+ end
+
+ module RodaPlugins
+ def self.register_plugin: (Symbol name, Module plugin) -> void
+ end
+end
diff --git a/sig/gems/sentry.rbs b/sig/gems/sentry.rbs
new file mode 100644
index 0000000..198f223
--- /dev/null
+++ b/sig/gems/sentry.rbs
@@ -0,0 +1,14 @@
+# Sentry is reached at exactly two points: the exception-capturing
+# middleware web.rb mounts, and the message the not_found handler sends.
+#
+# Events are left untyped. SentryScrubber walks whatever before_send
+# hands it, feature-detecting as it goes, and typing that shape would
+# claim more about the sentry-ruby internals than the scrubber assumes.
+module Sentry
+ def self.capture_message: (String message, ?level: Symbol) -> untyped
+
+ module Rack
+ class CaptureExceptions
+ end
+ end
+end
diff --git a/sig/pro_tacts.rbs b/sig/pro_tacts.rbs
new file mode 100644
index 0000000..d5d4cc3
--- /dev/null
+++ b/sig/pro_tacts.rbs
@@ -0,0 +1,9 @@
+# `class << self` is outside what inline RBS reads, so the module-level
+# config accessors are declared here.
+module ProTacts
+ self.@config: Config?
+
+ def self.config: () -> Config
+
+ def self.config=: (Config config) -> Config
+end
diff --git a/sig/pro_tacts/addressbook.rbs b/sig/pro_tacts/addressbook.rbs
new file mode 100644
index 0000000..f58f0d5
--- /dev/null
+++ b/sig/pro_tacts/addressbook.rbs
@@ -0,0 +1,15 @@
+# A Data class, like Contact: see sig/pro_tacts/contact.rbs for why the
+# signature is here rather than in the Ruby.
+module ProTacts
+ class Addressbook
+ attr_reader contacts: Array[Contact]
+
+ def self.new: (contacts: Array[Contact]) -> instance
+
+ def self.load: (Pathname | String directory) -> Addressbook
+
+ def ctag: () -> String
+
+ def sync_token: () -> String
+ end
+end
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
new file mode 100644
index 0000000..b5ee3a4
--- /dev/null
+++ b/sig/pro_tacts/contact.rbs
@@ -0,0 +1,19 @@
+# `class Contact < Data.define(...)` has no constant for a super class,
+# which is what inline RBS needs to read one (RBS docs/data_and_struct.md
+# covers the pattern). The class is marked `@rbs skip` in contact.rb and
+# its signature written out here instead.
+module ProTacts
+ class Contact
+ ID_FORMAT: Regexp
+
+ attr_reader id: String
+ attr_reader vcard: String
+ attr_reader etag: String
+
+ def self.new: (id: String, vcard: String, etag: String) -> instance
+
+ def self.all: (Pathname | String directory) -> Array[Contact]
+
+ def self.parse: (Pathname | String path) -> Contact
+ end
+end
diff --git a/sig/pro_tacts/vcard.rbs b/sig/pro_tacts/vcard.rbs
new file mode 100644
index 0000000..9ffbeb0
--- /dev/null
+++ b/sig/pro_tacts/vcard.rbs
@@ -0,0 +1,8 @@
+# `module_function` copies every method onto the module itself, and
+# inline RBS has no syntax for that. Extending the module is how RBS
+# says the same thing, so the method types can stay inline in vcard.rb.
+module ProTacts
+ module VCard
+ extend VCard
+ end
+end
diff --git a/sig/roda/plugins/dav_verbs.rbs b/sig/roda/plugins/dav_verbs.rbs
new file mode 100644
index 0000000..a39dea0
--- /dev/null
+++ b/sig/roda/plugins/dav_verbs.rbs
@@ -0,0 +1,21 @@
+# The plugin defines its request methods by evaluating a string, which
+# no signature can be derived from, so they are declared here. Their
+# shape matches Roda's own verb methods: match on the request method,
+# and route the block if it matches.
+class Roda
+ module RodaPlugins
+ module DavVerbs
+ module RequestMethods
+ def propfind: (*untyped matchers) { (*untyped) -> untyped } -> void
+
+ def report: (*untyped matchers) { (*untyped) -> untyped } -> void
+ end
+ end
+ end
+
+ class RodaRequest
+ # RBS has no way to say "only once plugin :dav_verbs is loaded", and
+ # web.rb — the only app here — loads it.
+ include RodaPlugins::DavVerbs::RequestMethods
+ end
+end