Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 2 additions & 23 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -1,26 +1,5 @@
#!/bin/sh
# Formats staged Swift files with swift-format before each commit.
# Lints and formats before each commit, so what CI checks is what you push.
# Enable once per clone: ./scripts/setup.sh (or git config core.hooksPath .githooks)
set -e

# NUL-delimited so paths with spaces survive. ACM excludes deletions.
files=$(git diff --cached --name-only --diff-filter=ACM -z -- '*.swift' | tr '\0' '\n')
[ -z "$files" ] && exit 0

if ! command -v swift >/dev/null 2>&1; then
echo "pre-commit: swift toolchain not found; skipping swift-format." >&2
exit 0
fi

echo "$files" | while IFS= read -r f; do
[ -n "$f" ] || continue
# A file with unstaged changes is partially staged; formatting in place and
# re-adding would pull the unstaged hunks into the commit. Skip it — CI's
# format check is the backstop for anything the hook can't safely touch.
if git diff --quiet -- "$f"; then
swift format --in-place "$f"
git add -- "$f"
else
echo "pre-commit: '$f' is partially staged; skipping format (run swift format manually)." >&2
fi
done
exec "$(git rev-parse --show-toplevel)/bin/lint" --staged
24 changes: 14 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,22 @@ env:
DEVELOPER_DIR: /Applications/Xcode_27.0.app/Contents/Developer

jobs:
swift-format-check:
name: swift-format check
runs-on: xcode-27
lint:
name: oida
# Linting needs no Xcode, so it runs on Linux where a runner minute is cheapest. bin/lint downloads
# oida, pinned by .oida-version and verified against .oida-checksum-<platform>, so CI and a laptop run
# the same bytes. The Swift toolchain container is what makes Linux work at all: oida's SourceKit-based
# rules and its swift-format handoff both resolve through the toolchain that ships in this image, the
# same way they resolve through an installed Xcode on a Mac. There is no baseline: any violation fails.
runs-on: ubuntu-latest
container: swift:6.3.2-noble
steps:
- uses: actions/checkout@v5
- name: Check formatting
run: |
swift format --in-place --recursive Sources Tests
if ! git diff --exit-code; then
echo "::error::Code is not formatted. Run: swift format --in-place --recursive Sources Tests (and commit)."
exit 1
fi
# curl isn't in the toolchain image; bin/lint needs it to fetch the pinned oida release.
- name: Install curl
run: apt-get update && apt-get install -y curl
- name: Lint
run: ./bin/lint --check

build-and-test:
name: macOS Swift Build & Test
Expand Down
1 change: 1 addition & 0 deletions .oida-checksum-linux-x86_64
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1df36e1a360fc0c613bce32bfd81d95ddd8d9f888c4bd3da223c305dce03ec88
1 change: 1 addition & 0 deletions .oida-checksum-macos-arm64
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
b8715e4c9a16a1c4e78d09ab3904cb6966bbce41d722f774b3846352f3f0c7f9
1 change: 1 addition & 0 deletions .oida-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.16.0
76 changes: 76 additions & 0 deletions .oida.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# The linter is oida (github.com/kolonialno/oida-swift), a SwiftLint fork carrying the rules we
# actually want. bin/lint fetches the pinned release and runs it; there is no baseline, so the tree
# is kept at zero and any violation fails.

opt_in_rules:
# The shape of every argument list, parameter list and condition: three or more elements go one per
# line, two or fewer go on one line, and both directions hold.
- multiline_call_arguments
- multiline_parameters
- multiline_conditions
# Imports in three groups — Apple, ours, third-party — alphabetical inside each. Correctable.
- grouped_imports
- key_path_only_where_the_api_takes_one
- multiline_string_opens_on_its_own_line
# What gets written too much of: a helper with one caller that returns nothing, a comment whose every
# word is already in the code below it, a banner naming what the declarations already name.
- comment_adds_no_word
- no_mark_comments
- no_single_use_void_functions
# A cast or a try that traps on anything the runtime hands it, and an override that overrides nothing.
- unneeded_override
- force_cast
- force_try
# VOICE.md's own contract, enforced on every root document.
- document_says_what_is
- document_avoids_retired_words
- document_links_resolve

disabled_rules:
- todo # work is tracked in PRs and LEARNINGS, not in-code markers
- unused_closure_parameter

excluded:
# Never lint vendored or generated code, wherever the build puts it. Matched by location, not path,
# so an in-repo `-derivedDataPath` does not drag SPM checkouts into scope.
- DerivedData
- build
- "**/.build"
- "**/SourcePackages"
- "**/GeneratedSources"
- "**/Intermediates.noindex"

# Errors, not warnings: what these find is written by whoever is not reading the code around it, and a
# warning is what a reader scrolls past.
comment_adds_no_word:
severity: error
no_mark_comments:
severity: error

opening_brace:
# A condition or a signature that spans several lines gets its brace on its own line — that is what
# the formatter emits, and asking for anything else is asking for a shape it will undo.
ignore_multiline_statement_conditions: true
ignore_multiline_function_signatures: true

identifier_name:
min_length: 1
max_length: 70

type_name:
max_length: 45

multiline_call_arguments:
max_number_of_single_line_parameters: 2

multiline_parameters:
max_number_of_single_line_parameters: 2

multiline_conditions:
max_number_of_single_line_parameters: 2

grouped_imports:
# This package ships one product and takes no dependencies, so "ours" is the module itself and its
# test target, which is only ever @testable-imported.
our_modules:
- Networking
1 change: 1 addition & 0 deletions .swift-format
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"spaces": 4
},
"maximumBlankLines": 1,
"multiElementCollectionTrailingCommas": false,
"respectsExistingLineBreaks": true,
"lineBreakBeforeEachArgument": false
}
14 changes: 7 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Swift API Design Guidelines whole, so a naming question is answered by reading i
- `make httpbin` / `make httpbin-stop` — run go-httpbin on `:8080` to iterate with plain `swift test`.
- `swift build` — library only; CI also fails on any first-party `warning:`.
- Integration tests need go-httpbin (`TestConfig.httpbinBaseURL`, default `http://127.0.0.1:8080`). Without it
they fail fast (connection refused) — intended, not a flake. The offline / `fake*` suites need no server.
they fail fast (connection refused) — intended behaviour rather than a flake. The offline / `fake*` suites run on their own.
- Formatting: `.swift-format` (120 cols, 4-space). Run `./scripts/setup.sh` once per clone to enable the
`.githooks/pre-commit` hook (formats staged Swift files); CI's format check is the backstop.
- CI: the `xcode-27` runner image / Xcode 27 / Swift 6.4, the same toolchain as local — CI is the gate.
Expand Down Expand Up @@ -47,29 +47,29 @@ Swift API Design Guidelines whole, so a naming question is answered by reading i
Cross-file contracts an agent must hold; the *why* lives in each file's comments.

- **Actor isolation.** `Networking` is an actor — isolated members need `await`, config is async setters
(no external property mutation), no subclassing.
- **Typed bodies, no `Any`.** The method picks the encoding (`body:` JSON / `form:` url-encoded /
(mutation goes through them alone), and the type is final.
- **Typed bodies, chosen by the method.** The method picks the encoding (`body:` JSON / `form:` url-encoded /
`parts:`+`fields:` multipart / `data:contentType:` raw; `query:` for get/delete), via the `RequestBody`
enum. `T` ∈ any `Decodable` · `Data` · `Void` · `JSONResponse`.
- **Errors say *where* it failed**, never a stringified catch-all; the core never parses the error body —
- **Errors say *where* it failed**, each a typed case; the core hands the error body on untouched —
`ResponseMetadata.body` is the full bytes, the caller decodes its own envelope.
- **Interceptors are an onion *below* the decode layer**, over a raw `HTTPExchange`; registered
outermost-first, `next` replays. Downloads route through too; a GET cache hit flows back out through the
chain (the cache is the innermost layer).
- **Cache reads are pure.** `.memory`/`.none` never touch disk, so a read can't destroy a durable
- **Cache reads are pure.** `.memory`/`.none` stay in memory, so a read leaves a durable
`.memoryAndFile` copy — purging belongs to the write path and `clearCache`. Sliding TTL keyed on file
mtime; sharded layout; one-shard-per-launch background sweep. The `NSCache` warm tier can be evicted by
iOS at any time — disk is the durable fallback.
- **Observability is one hook:** `events()` (multi-consumer `AsyncStream`). Built-in logging is separate,
synchronous, gated by `logLevel`; redaction is log-path only, so `events()` carries the real headers.
- **Region-isolation trap (Swift 6.2):** before building the `@Sendable` interceptor chain, read actor
state (`session`/`collector`) into locals so the closure captures none.
state (`session`/`collector`) into locals, so the closure captures values alone.

## Boundaries

- **Always** run `make test` (Docker) to green and keep the build warning-free before claiming done.
- **Always** update `README.md` and `CHANGELOG.md` in the same PR as any public-API change — the README
snippets are copy-paste docs and must compile under the actor (`await`).
- **Ask first** before adding a third-party dependency — the core is intentionally dependency-free.
- **Never** put cache purging back on the read path: `.memory`/`.none` reads must stay pure, or an
- **Keep cache purging off the read path**: `.memory`/`.none` reads must stay pure, or an
evicted warm tier turns a recoverable miss into permanent loss.
6 changes: 3 additions & 3 deletions LEARNINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ LEARNINGS/2026-09-15-1520-a-warm-build-hides-a-warning.md
The filename is the whole of the discipline. As many branches write this repository at once as there are
worktrees open, and a file apiece is what lets them: each branch adds a path the others lack, so a merge
takes every side whole and the file you wrote arrives byte for byte. Sharing one file costs a conflict per
pair of open branches, and `merge=union` in `.gitattributes` does not save you — it is read off a **working
tree**, which a server-side merge lacks, so it clears the conflicts on your machine and none of the ones
that block a merge button.
pair of open branches, and `merge=union` in `.gitattributes` applies only where a **working
tree** exists, which a server-side merge lacks — so it clears the conflicts on your machine while every
one that blocks a merge button stands.

Inside:

Expand Down
7 changes: 2 additions & 5 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,9 @@ let package = Package(
.iOS(.v18), .macOS(.v15), .tvOS(.v18), .watchOS(.v11)
],
products: [
.library(
name: "Networking",
targets: ["Networking"]),
],
dependencies: [
.library(name: "Networking", targets: ["Networking"])
],
dependencies: [],
targets: [
.target(
name: "Networking",
Expand Down
Loading