Prefer Pathname over File for paths
Build paths with Pathname and convert to a String only where a caller
needs one. Record the convention in AGENTS.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RTqTY7HvPy5e5RvUQ2TQdH
change
commit d1547a7389f1d9662746a549907126e5a1fd3f16
author Claude <noreply@anthropic.com>
date
parent dae8ff1b
diff --git a/AGENTS.md b/AGENTS.md
index 8de48bb..fbc079e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -87,6 +87,10 @@ Tests set `DATABASE_URL=:memory:` in `test/test_helper.rb`.
 - Every request needs a `Remote-User` header or it gets a 401. That is safe only because a proxy (Tailscale identity) writes the header. `StubLogin` fills it in for `demo.ru` (dev server, Fly apps) and nowhere else: never in `config.ru`, and never in front of real data.
 - Lists are private to their owner (404 for anyone else); lists from before users existed have no owner and are visible to everyone
 - Config is a frozen `Data.define` value object
+- Paths are `Pathname`s, not strings built with `File`/`Dir`
+  (`Pathname(__dir__).join(...)`, `#exist?`, `#read`). Convert with
+  `to_s` only at a boundary that wants a String, such as `$LOAD_PATH`,
+  `Rack::Builder.parse_file`, or `Sequel::Migrator.run`.
 - Types are inline RBS comments (`#:` before a method, `# @rbs` for the
   rest), checked by `rake steep`. `sig/` holds only what that syntax
   cannot say, each file saying why: gems with no signatures (Sequel,
diff --git a/Rakefile b/Rakefile
index f07e090..7cb2df0 100644
--- a/Rakefile
+++ b/Rakefile
@@ -1,4 +1,6 @@
-$LOAD_PATH.unshift File.expand_path("lib", __dir__)
+require "pathname"
+
+$LOAD_PATH.unshift Pathname(__dir__).join("lib").to_s
 
 require "minitest/test_task"
 
diff --git a/config.ru b/config.ru
index e7331d6..1f8d7bd 100644
--- a/config.ru
+++ b/config.ru
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
-$LOAD_PATH.unshift(File.expand_path("lib", __dir__))
+require "pathname"
+
+$LOAD_PATH.unshift(Pathname(__dir__).join("lib").to_s)
 
 require_relative "lib/rank_king/web"
 
diff --git a/demo.ru b/demo.ru
index e9958a2..db3bf51 100644
--- a/demo.ru
+++ b/demo.ru
@@ -4,9 +4,11 @@
 # write it. The dev server and the Fly apps serve from here; a deployment
 # behind the proxy runs config.ru alone.
 
-$LOAD_PATH.unshift(File.expand_path("lib", __dir__))
+require "pathname"
+
+$LOAD_PATH.unshift(Pathname(__dir__).join("lib").to_s)
 
 require "rank_king/stub_login"
 
 use RankKing::StubLogin
-run Rack::Builder.parse_file(File.expand_path("config.ru", __dir__))
+run Rack::Builder.parse_file(Pathname(__dir__).join("config.ru").to_s)
diff --git a/lib/rank_king/db.rb b/lib/rank_king/db.rb
index d9ffb7a..8a1d60d 100644
--- a/lib/rank_king/db.rb
+++ b/lib/rank_king/db.rb
@@ -1,5 +1,6 @@
 # frozen_string_literal: true
 
+require "pathname"
 require "sequel"
 
 require_relative "config"
@@ -9,9 +10,8 @@ module RankKing
   Sequel.extension :migration
   # `__dir__` is nil only for code with no file behind it, which a
   # required library is not, and the assertion says so to Steep.
-  MIGRATIONS = File.expand_path(
-    "../../db/migrate",
+  MIGRATIONS = Pathname(
     __dir__, #: String
-  )
-  Sequel::Migrator.run(DB, MIGRATIONS)
+  ).join("../../db/migrate").expand_path
+  Sequel::Migrator.run(DB, MIGRATIONS.to_s)
 end
diff --git a/lib/rank_king/views/layout.rb b/lib/rank_king/views/layout.rb
index b4b6cf8..f1321c3 100644
--- a/lib/rank_king/views/layout.rb
+++ b/lib/rank_king/views/layout.rb
@@ -1,20 +1,22 @@
 # frozen_string_literal: true
 
 require "digest"
+require "pathname"
 require "phlex"
 
 module RankKing
   module Views
     class Layout < Phlex::HTML
       ASSET_VERSIONS = begin
-        root = File.expand_path("../../../public", __dir__)
+        root = Pathname(__dir__).join("../../../public").expand_path
         %w[
           /css/reset.css
           /css/utopia.css
           /css/app.css
         ].to_h {|path|
-          file = File.join(root, path)
-          [path, File.exist?(file) ? Digest::MD5.file(file).hexdigest[0, 10] : "0"]
+          # Pathname#join would treat the leading slash as a new root.
+          file = root.join(path.delete_prefix("/"))
+          [path, file.exist? ? Digest::MD5.file(file).hexdigest[0, 10] : "0"]
         }.freeze
       end