Repository navigation
noGuardInTests: add --boolean-guards-in-tests to preserve boolean guards - #2691
Conversation
Converting a guard with a boolean condition drops its early exit. Under XCTest the replacement `XCTAssert(...)` records a failure but does not halt, so code after the guard now runs with the condition false — the case reported in nicklockwood#2193, where a `guard polygons.count > 1 else { return }` bounds check disappeared and the following `polygons[0]` became reachable on an empty array. Add `--boolean-guards-in-tests`, defaulting to `convert` so existing behaviour is unchanged, and skip guards containing a boolean condition when set to `preserve`. Optional-binding guards still convert in both modes, since `try XCTUnwrap` / `try #require` throw and so preserve the early exit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017D7mLNAWavucy6fFFPZise
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #2691 +/- ##
===========================================
+ Coverage 95.45% 95.50% +0.05%
===========================================
Files 179 182 +3
Lines 27384 27809 +425
===========================================
+ Hits 26140 26560 +420
- Misses 1244 1249 +5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| // Skip if #available / #unavailable (can't be converted to #expect) | ||
| return true | ||
| case .booleanExpression: | ||
| // Converting a boolean guard discards its early exit, so any code |
There was a problem hiding this comment.
I believe this is only true in XCTest, not Swift Testing, since we generate try #require(myBooleanCondition) in Swift Testing.
How about we also include a --boolean-guards-in-tests preserve-xctest and make that the default value. That preserves the existing default behavior for Swift Testing.
There was a problem hiding this comment.
You're right, and I had it wrong — I treated both frameworks the same when only XCTest loses the early exit. try #require throws, so converting under Swift Testing was always safe.
Done as you suggested: preserve-xctest is now the default, with preserve and convert as the other two. Swift Testing behaviour is unchanged from before this PR.
Ten existing tests asserted XCTest conversion, so they now pass .convert explicitly rather than relying on the default — what each is checking is visible at the call site. 82 tests green, and Rules.md is regenerated.
calda is right that the early exit is only lost under XCTest. Swift Testing generates `try #require(condition)`, which throws, so converting a boolean guard there preserves the exit and is safe. The first version of this option treated both frameworks the same and so preserved guards in Swift Testing for no reason. `--boolean-guards-in-tests` becomes a three-case option: preserve-xctest (default) preserve under XCTest, convert under Swift Testing preserve preserve under both convert convert under both, the behaviour before this option The default fixes the unsound case and leaves Swift Testing exactly as it was, which is what calda asked for. Ten existing tests asserted XCTest conversion and now pass `.convert` explicitly, so what each one is checking is visible at the call site rather than riding on a default. Rules.md is regenerated by MetadataTests. NoGuardInTestsTests: 82 tests, 0 failures. MetadataTests: 14, 0 failures.
The Lint job caught a single-line if body, which wrapIfStatementBodies does not allow. Embarrassing place to leave a formatting violation. Formatted with the build from this branch, so it is the same rules CI runs. NoGuardInTestsTests still 82 passing.
noGuardInTestsconverts a guard with a boolean condition into a bare assertion, which drops the guard's early exit. Under XCTest the replacement isXCTAssert(...), which records a failure but does not stop the test, so everything after the guard now executes with the condition false.This is the case Nick reported in #2193 — the bounds check disappears and the array subscript that it protected becomes reachable on an empty array.
Reproduction against
main(0256422, 0.63.0),swiftformat Guard.swift --rules noGuardInTests --swiftversion 6.1:With
polygonsempty, the input fails cleanly and returns; the output reports the assertion failure and then traps onpolygons[0].Worth noting that the rule already contains the reasoning for this, but reaches the opposite conclusion for guards:
The guard's else block does not handle the early exit after the transform, because the transform deletes it.
The change
Adds
--boolean-guards-in-tests, mirroring the shape of--guard-like-if-statements(#2588):convert(default) — today's behaviour, unchanged.preserve— skip any guard that contains a boolean condition.Optional-binding guards still convert in both modes:
try XCTUnwrap(...)andtry #require(...)throw, so the early exit survives. A guard mixing the two (guard someCondition, let value = optionalValue else) is preserved underpreserve, since the boolean half is the unsafe half — this matches Nick's framing of "only removeguard letconstructs and not other types of guard".The judgement call
I defaulted to
convert, i.e. no behaviour change for anyone currently using the rule. That follows Cal's note that he hit this two or three times and chose to leave it in, and matches how--guard-like-if-statementsshipped (default = existing behaviour).The alternative is defaulting to
preserveon the grounds that the current output is unsound rather than merely opinionated. There is a real argument for it — the transform is never semantics-preserving for a boolean guard under XCTest — andnoGuardInTestsisdisabledByDefault, so the blast radius of flipping the default is small. I did not want to make that call unilaterally. Happy to switch it if you would rather; it is a one-line change plus test updates.A narrower option is also possible: restrict the skip to XCTest only, since under Swift Testing
try #require(...)throws and the conversion is semantics-preserving. I made the option framework-independent because the request in #2193 reads as a style preference about guards in general, not only about the unsound case. Also easy to narrow if you prefer.The option name is the part I am least attached to.
Verification
swift teston macOS arm64 (Swift 6.2): the 5 new cases pass and theNoGuardInTestsTestssuite is green (82 tests, 0 failures).CommandLineTests.testBadConfigFails2and fourSwiftFormatTests.testInputFile*cases — reproduce identically on a cleanmaincheckout with no changes, so they are pre-existing here and unrelated. CI will need to confirm Linux.Rules.mdis the regenerated output fromMetadataTests, not hand-edited.Fixes #2193.