diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 252039e..3c4544e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "fosmvvm-generators", "description": "FOSMVVM architecture generators for ViewModels, Fields, DataModels, ServerRequests, Leaf Views, and ViewModel Tests", - "version": "2.66.0", + "version": "2.67.0", "author": { "name": "FOS Computer Services" }, diff --git a/.claude/skills/fosmvvm-review/checks/serverrequest.md b/.claude/skills/fosmvvm-review/checks/serverrequest.md index 3f51220..f7a379c 100644 --- a/.claude/skills/fosmvvm-review/checks/serverrequest.md +++ b/.claude/skills/fosmvvm-review/checks/serverrequest.md @@ -71,7 +71,7 @@ public struct ResponseError: ServerRequestError { public let reason: String } ``` -**Detection:** Grep the `ResponseError` declarations and their documentation for reasoning about 401s, credential rejection, or `EmptyError` swallowing errors. Flag it: `WireError` decodes `CredentialRejectedError` strictly before the request's own error type, so the rejection is never reachable by the `ResponseError` and the defensive shape buys nothing. It also costs something — a permissive error decodes any abort body, so unrelated failures arrive wearing this operation's type. +**Detection:** Grep the `ResponseError` declarations and their documentation for reasoning about 401s, credential rejection, or `EmptyError` swallowing errors. Flag it: every error body crosses inside one typed envelope that names whether it carries the surface rejection or the request's own error (0.16.0; before that, the rejection was decoded strictly first), so the rejection is never reachable by the `ResponseError` and the defensive shape buys nothing. It also costs something — a permissive error decodes any abort body, so unrelated failures arrive wearing this operation's type. **Report this once for the whole area when the rationale has propagated**, listing every site in the body. A copied justification is one belief, not N defects, and filing it per request buries the fact that it spread. diff --git a/.claude/skills/fosmvvm-serverrequest-generator/SKILL.md b/.claude/skills/fosmvvm-serverrequest-generator/SKILL.md index 9e85905..c04a770 100644 --- a/.claude/skills/fosmvvm-serverrequest-generator/SKILL.md +++ b/.claude/skills/fosmvvm-serverrequest-generator/SKILL.md @@ -165,29 +165,30 @@ A rejected credential reaches the client as `CredentialRejectedError`, a FOS-owned error, **regardless of what your `ResponseError` is**. Catch it; never branch on the 401. -This matters because of the decode order. `WireError` tries the well-known -surface errors *strictly before* the request's own `ResponseError`: +This holds because of the wire's shape, not a decode order. Every error body +crosses inside one typed envelope (0.16.0) — exactly one of the surface +rejection or the request's own `ResponseError` — encoded by the server's +`ErrorMiddleware` and decoded by the client: ```swift -package init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let rejection = try? container.decode(CredentialRejectedError.self) { - self = .surface(rejection) - } else { - self = try .response(container.decode(E.self)) - } +package enum WireError: Error, Codable { + case surface(CredentialRejectedError) + case response(E) } ``` -`CredentialRejectedError` wins that race unconditionally. So: +The envelope names which one it carries, so nothing is tried and nothing can +be mistaken for anything else — a permissive `ResponseError` (`EmptyError` +decodes from anything) cannot swallow a rejection, and a request error with a +field named `reason` cannot pun into one. So: - ✅ `EmptyError` is safe on a request behind a credential middleware. It cannot - swallow a rejection — the rejection is decoded first and never reaches it. + swallow a rejection — the envelope carries the rejection in its own case, and + `EmptyError` is only ever asked to decode the `response` case. - ❌ Do **not** add a permissive `String` field to a `ResponseError` "so a 401 - isn't swallowed." That was never a real risk, and the permissive field creates - a different one: an error that decodes anything will happily decode any - `Abort(reason:)` body as a successful match, so unrelated failures arrive - wearing your operation's error type. + isn't swallowed." That was never a real risk, and the field buys nothing: a + body that is not the envelope — Vapor's stock abort, a proxy's error page — + never reaches your error type at all; it falls to the status path. If your operation has no well-defined throw, use `EmptyError`. A required free-text `reason: String` is not a lighter-weight error — it is an error with diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88c7ad0..791e4d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,14 @@ jobs: steps: - uses: actions/checkout@v4 + # `brew install` leaves a preinstalled older formula alone, and the + # macos-latest image pool has carried two SwiftFormat versions at once; + # `brew upgrade` makes latest mean latest on every runner. - name: Install linters - run: brew install swiftformat swiftlint + run: | + brew update + brew install swiftformat swiftlint + brew upgrade swiftformat swiftlint # --lint reports without writing. `swiftformat .` is the fix. # fileHeader is excluded here, not in .swiftformat: its template year is diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f2f5b1..a14ed92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Skill text teaches the envelope, not the decode order** — the serverrequest + generator's credential-rejection section and the review's + `no-defensive-error-for-credential-rejection` check both described the retired + trial-decode; both now state the typed envelope 0.16.0 ships. Plugin 2.67.0. + ## [0.16.0] - 2026-09-03 ### Added diff --git a/Sources/FOSMVVM/SwiftUI Support/FormFieldView.swift b/Sources/FOSMVVM/SwiftUI Support/FormFieldView.swift index de2feb4..4101f56 100644 --- a/Sources/FOSMVVM/SwiftUI Support/FormFieldView.swift +++ b/Sources/FOSMVVM/SwiftUI Support/FormFieldView.swift @@ -276,7 +276,7 @@ private extension FormFieldView where Value == String { } .textContentType(.password) #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) - .textInputAutocapitalization(.never) + .textInputAutocapitalization(.never) #endif case .text, .location, .fullStreetAddress, .streetAddressLine1, .streetAddressLine2: @@ -293,10 +293,10 @@ private extension FormFieldView where Value == String { } .disableAutocorrection(fieldModel.formField.autocomplete == .off) #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) - .textInputAutocapitalization( - fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .sentences - ) - .textContentType(fieldModel.formField.textContentType) + .textInputAutocapitalization( + fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .sentences + ) + .textContentType(fieldModel.formField.textContentType) #endif case .name, .namePrefix, .givenName, .middleName, .familyName, .nameSuffix, .nickname, @@ -314,10 +314,10 @@ private extension FormFieldView where Value == String { } .disableAutocorrection(fieldModel.formField.autocomplete == .off) #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) - .textInputAutocapitalization( - fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .words - ) - .textContentType(fieldModel.formField.textContentType) + .textInputAutocapitalization( + fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .words + ) + .textContentType(fieldModel.formField.textContentType) #endif case .postalCode, .creditCardNumber, .oneTimeCode: @@ -334,8 +334,8 @@ private extension FormFieldView where Value == String { } .disableAutocorrection(fieldModel.formField.autocomplete == .off) #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) - .textInputAutocapitalization(fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .never) - .textContentType(fieldModel.formField.textContentType) + .textInputAutocapitalization(fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .never) + .textContentType(fieldModel.formField.textContentType) #endif case .telephoneNumber: @@ -352,7 +352,7 @@ private extension FormFieldView where Value == String { } .disableAutocorrection(fieldModel.formField.autocomplete == .off) #if os(iOS) || os(tvOS) || os(visionOS) || targetEnvironment(macCatalyst) - .keyboardType(.phonePad) + .keyboardType(.phonePad) #endif #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) .textContentType(fieldModel.formField.textContentType) @@ -372,7 +372,7 @@ private extension FormFieldView where Value == String { } .disableAutocorrection(true) #if os(iOS) || os(tvOS) || os(visionOS) || targetEnvironment(macCatalyst) - .keyboardType(.emailAddress) + .keyboardType(.emailAddress) #endif #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) .textContentType(fieldModel.formField.textContentType) @@ -408,8 +408,8 @@ private extension FormFieldView where Value == String? { } .disableAutocorrection(fieldModel.formField.autocomplete == .off) #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) - .textInputAutocapitalization(fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .sentences) - .textContentType(fieldModel.formField.textContentType) + .textInputAutocapitalization(fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .sentences) + .textContentType(fieldModel.formField.textContentType) #endif case .name, .namePrefix, .givenName, .middleName, .familyName, .nameSuffix, .nickname, @@ -427,8 +427,8 @@ private extension FormFieldView where Value == String? { } .disableAutocorrection(fieldModel.formField.autocomplete == .off) #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) - .textInputAutocapitalization(fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .words) - .textContentType(fieldModel.formField.textContentType) + .textInputAutocapitalization(fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .words) + .textContentType(fieldModel.formField.textContentType) #endif case .postalCode, .creditCardNumber, .oneTimeCode: @@ -445,10 +445,10 @@ private extension FormFieldView where Value == String? { } .disableAutocorrection(fieldModel.formField.autocomplete == .off) #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) - .textInputAutocapitalization( - fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .never - ) - .textContentType(fieldModel.formField.textContentType) + .textInputAutocapitalization( + fieldModel.formField.autocapitalize?.textAutocapitalizationType ?? .never + ) + .textContentType(fieldModel.formField.textContentType) #endif case .telephoneNumber: @@ -465,7 +465,7 @@ private extension FormFieldView where Value == String? { } .disableAutocorrection(fieldModel.formField.autocomplete == .off) #if os(iOS) || os(tvOS) || os(visionOS) || targetEnvironment(macCatalyst) - .keyboardType(.phonePad) + .keyboardType(.phonePad) #endif #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) .textContentType(fieldModel.formField.textContentType) @@ -485,7 +485,7 @@ private extension FormFieldView where Value == String? { } .disableAutocorrection(true) #if os(iOS) || os(tvOS) || os(visionOS) || targetEnvironment(macCatalyst) - .keyboardType(.emailAddress) + .keyboardType(.emailAddress) #endif #if os(iOS) || os(tvOS) || os(visionOS) || os(watchOS) || targetEnvironment(macCatalyst) .textContentType(fieldModel.formField.textContentType) diff --git a/Sources/FOSMVVM/SwiftUI Support/TestHost.swift b/Sources/FOSMVVM/SwiftUI Support/TestHost.swift index 85a5876..aaddd27 100644 --- a/Sources/FOSMVVM/SwiftUI Support/TestHost.swift +++ b/Sources/FOSMVVM/SwiftUI Support/TestHost.swift @@ -186,11 +186,11 @@ private struct TestingView: View { var body: some View { testView - #if os(iOS) - .onAppear { - DismissKeyboardWindow.install() - } - #endif + #if os(iOS) + .onAppear { + DismissKeyboardWindow.install() + } + #endif } init(baseView: BaseView) { @@ -350,32 +350,25 @@ enum TestHostDiagnostic { return """ ================================================================================ - FOSMVVM testHost(): cannot present the view under test. - + FOSMVVM testHost(): cannot present the view under test.\n The test harness asked for the view whose ViewModel is: - \(viewModelType) - + \(viewModelType)\n Registered ViewModels: - \(registeredList) - - \(cause) - - To fix, register the *View* (not the ViewModel) from your App's init(): - + \(registeredList)\n + \(cause)\n + To fix, register the *View* (not the ViewModel) from your App's init():\n @main struct MyApp: App { init() { MVVMEnvironment.registerTestingViews() } - } - + }\n private extension MVVMEnvironment { @MainActor static func registerTestingViews() { #if DEBUG registerTestView(MyView.self) // where MyView.VM == \(viewModelType) #endif } - } - + }\n See the documentation for MVVMEnvironment.registerTestView(_:). ================================================================================ """ @@ -391,19 +384,15 @@ enum TestHostDiagnostic { static func undecodableViewModel(viewModelType: String, error: any Error) -> String { """ ================================================================================ - FOSMVVM testHost(): cannot decode the ViewModel for the view under test. - + FOSMVVM testHost(): cannot decode the ViewModel for the view under test.\n ViewModel type requested by the test harness: - \(viewModelType) - + \(viewModelType)\n Decoding error: - \(error) - + \(error)\n The view registered for '\(viewModelType)' has a different VM associated type than the payload the test sent, or that payload is not valid JSON for it. Check that the view passed to MVVMEnvironment.registerTestView(_:) is the one whose VM is '\(viewModelType)', and that - the test's ViewModel generic argument matches it. - + the test's ViewModel generic argument matches it.\n See the documentation for MVVMEnvironment.registerTestView(_:). ================================================================================ """