Add a Swift conformance runner so the suite pins Swift behaviour
make conformance-swift has printed SKIP since the target was added: conformance/runner/swift/Package.swift did not exist, and test.yml had no Swift conformance job. Every fixture the suite states — the configOverrides.maxRetries one-attempt floor from #183, the same-origin and protocol-downgrade Link rules, request shapes, error mapping — was pinned for Go, TypeScript, Ruby, Kotlin and Rust but only by unit tests, or nothing, for Swift. This ports the shape of basecamp-sdk's Swift runner to fizzy and wires it in.
What the runner pins
conformance/runner/swift/ is a SwiftPM package with two source targets and a test target:
ConformanceRunner(executable) drives every case through the SDK's generated service methods, not raw verbs. The Go, Ruby, TypeScript and Rust runners use raw verbs, but Swift's per-operation retry policy lives in the generatedMetadatatable that only the service methods consult, so a typed dispatch table — one arm per operation in the route table, like the Kotlin runner — is the only path that pins the retry and idempotency fixtures against the SDK's own policy. The transport is the SDK's publicTransportseam (ScriptedTransport: answers from the fixture queue, records every request with a monotonic timestamp, no sockets, no@testable).ConformanceSupport(SDK-free) holds the assertion contracts whose failing branches never execute against a passing fixture: the every-gapdelayBetweenRequestscheck, fixture-path rendering and matching, and themaxRetries→enableRetrymapping. 26 Swift Testing cases pin them (make conformance-runner-tests-swift, whichconformance-swiftruns first).
The retry floor is the point of the card. Swift exposes no numeric cap — HTTPClient runs max(enableRetry ? maxAttempts : 1, 1) off each operation's generated retry.max — so configOverrides.maxRetries maps onto enableRetry with the > 1 cutoff the schema states for TypeScript and Swift (0 and 1 both spell "exactly one attempt"). The mapping is unit-tested, and the fixture is live: mis-mapping a cap of 0 to retries-on makes max_retries 0 sends exactly one request on a governed operation fail with Expected 1 requests, got 3.
Beyond the explicit assertions, every case is held to three implicit invariants because the scripted transport answers any request: the verb on every hop must be the fixture's, the path on every non-link-following hop must render from the fixture's template (with Fizzy's .json-optional suffix as the one allowance, the same one the Rust runner's route validation makes), and a request that follows a rel="next" link must fetch exactly the advertised URL, query included. The link parser behind that exemption is a line-for-line mirror of the SDK's parseNextLink, and a relative target resolves against the request that carried the link, as the SDK resolves against the previous response URL. Each of those was checked by mutation: a wrong endpoint, a wrong verb, a missing body field and a dropped backoff all fail with a message naming the discrepancy.
HTTPS enforcement is a preconditionFailure in FizzyClient.init, so the HTTPS enforced for non-localhost case runs the binary as a child in --https-probe mode and requires it to die, as the basecamp-sdk runner does; a surviving child fails the case.
Two things the runner refuses rather than skips: an assertion type it records no observation for (responseBody, requestScheme — in the schema, used by no fixture) fails with "Unsupported assertion type", and a mock body that lacks a field the Swift model requires fails with the missing key and coding path.
Fixture bodies
That second refusal found four bodies under-specified against openapi.json's required lists: GetAccountSettings (paths.json and status-codes.json) and UpdateAccountEntropy return AccountSettings, which requires id, cards_count and created_at; GetJoinCode returns JoinCode, which requires url. The other runners never noticed — four drive raw verbs and Kotlin decodes with coerceInputValues. The bodies now carry the required fields; no assertion in those cases reads them.
Result
make conformance-swift locally (Swift 6.4, macOS): 128 passed, 0 failed, 128 total — every case in the ten fixture families, no skips. The same on Linux (Swift 6.3.3, x86_64): the Makefile target is not platform-gated, and the runner compiles and passes there too. The support tests pass 26/26, make swift-check still passes 43/43. Nothing in the Swift SDK needed to change, so there is no stacked SDK fix.
CI
Conformance (Swift) in test.yml runs on macos-15 after test-swift, mirroring the Kotlin and Rust conformance jobs: pinned checkout with persist-credentials: false, then make conformance-runner-tests-swift and make conformance-swift. actionlint and zizmor are clean. The Makefile's SKIP branch is gone; conformance-swift depends on the runner tests, conformance now runs all six runners, and CONTRIBUTING gains a table of the runners and their targets.
One packaging note, stated in Package.swift: SwiftPM derives package identity from the last path component, so a path dependency on ../../../swift from a package that also lives in a directory named swift is silently taken as a self-reference. basecamp-sdk avoids this with a root distribution manifest; that is a consumer-facing surface fizzy-sdk does not have today and a separate decision, so the runner reaches the SDK through a FizzySDK symlink instead, which gives the dependency its own identity without renaming anything.
Closes the card "[fizzy-sdk][Swift] No Swift conformance runner: the suite cannot pin Swift behaviour".