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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/fosmvvm-review/checks/serverrequest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
31 changes: 16 additions & 15 deletions .claude/skills/fosmvvm-serverrequest-generator/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<E: ServerRequestError>: 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
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 23 additions & 23 deletions Sources/FOSMVVM/SwiftUI Support/FormFieldView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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)
Expand Down
43 changes: 16 additions & 27 deletions Sources/FOSMVVM/SwiftUI Support/TestHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,11 @@ private struct TestingView<BaseView: View>: View {

var body: some View {
testView
#if os(iOS)
.onAppear {
DismissKeyboardWindow.install()
}
#endif
#if os(iOS)
.onAppear {
DismissKeyboardWindow.install()
}
#endif
}

init(baseView: BaseView) {
Expand Down Expand Up @@ -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(_:).
================================================================================
"""
Expand All @@ -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(_:).
================================================================================
"""
Expand Down
Loading