Contract 13
Build enforcement
A convention nobody checks is a suggestion. Stoic's rules (bound every wait, no escape hatch without a reason, no blocking in async code) live in AGENTS.md and in review comments, and they erode at the speed of a deadline. This contract moves them into the build: the patterns Stoic exists to replace fail the build, with a message that names the replacement.
It works in three layers, because no single mechanism can do the whole job. The order is the order of DESIGN.md principle 8: the compiler first, the build plugin second, the docs third.
The three layers
(a) Stoic builds itself with warnings as errors. Every Stoic target
carries .treatAllWarnings(as: .error), strict memory safety and the
upcoming-feature flags (Package.swift, strict). A library that asks apps
to be strict cannot ship code that is not. This binds Stoic's own sources
only: SwiftPM strips warning-control settings from a package built as a
dependency (SE-0480), so a library cannot force them on the app that uses
it. That is the right default for a dependency, and the reason layers (b) and
(c) exist.
(b) The app turns the compiler up on its own targets. Swift 6 language mode makes data races errors; warnings-as-errors makes everything else the compiler knows into one. This is the cheapest layer and catches the most. It is the app's manifest that must say so:
.target(
name: "App",
dependencies: [.product(name: "Stoic", package: "stoic")],
swiftSettings: [
.swiftLanguageMode(.v6),
.treatAllWarnings(as: .error),
.strictMemorySafety(),
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
]
)In an Xcode project the same settings are SWIFT_VERSION = 6 and
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES. (For one noisy warning group,
SE-0443's -Wwarning <group> keeps it a warning while everything else
errors.)
(c) The lint plugin catches what the compiler cannot see. The compiler
accepts semaphore.wait() in a wrapper type, Task.detached, an
AsyncStream with its default unbounded buffer, try? on a statement,
@unchecked Sendable with no stated reason. They are legal, and the
robustness bugs Stoic exists to prevent. StoicLint is a build-tool plugin
that parses the target's sources and fails the build on them.
Where the layers overlap, the compiler's diagnostic wins and the lint stays
quiet only by being syntactic: on a DispatchSemaphore the compiler already
rejects wait() in async code ("unavailable from asynchronous contexts"),
and the lint rule fires too, because it also covers wrappers the compiler
cannot judge. Two errors for one line is acceptable; a silent miss is not.
Origin
- SwiftLint's plugin model. A build-tool plugin runs a host tool over a target's sources and turns its output into build issues, with a baseline for adoption. Stoic's differs in scope (rules about robustness, not style, all errors, none warnings) and in carrying its own parser, so it needs no installed binary.
- SE-0443, SE-0480 and the warning-control family. Warning groups and
-Werror <group>(SE-0443), and SwiftPM settings built on them (SE-0480), are layer (b). SE-0522 is named in the brief for this work and its text was not re-read for this document; nothing here depends on it. - Rust's clippy. Lints that name the replacement ("use
x.is_empty()") and a#[allow(clippy::x)]that sits beside the code, rather than a global switch.// stoic:allow(rule): reasonis that, with the reason mandatory. - Ratchets. Record today's violations, fail on any new one, let the count
fall and never rise (the technique from large-codebase lint migrations, and
from
cargo clippybaselines in CI). A rule that cannot be adopted gradually is not adopted.
API
Enabling it
Consumers opt in with the Lint trait, which is what makes swift-syntax a
dependency at all (see Packaging):
// Package.swift of the app
let package = Package(
name: "App",
dependencies: [
.package(url: "https://github.com/Vaccone-Software/stoic", from: "0.1.0", traits: ["Lint"]),
],
targets: [
.target(
name: "App",
dependencies: [.product(name: "Stoic", package: "stoic")],
swiftSettings: [.swiftLanguageMode(.v6), .treatAllWarnings(as: .error)],
plugins: [.plugin(name: "StoicLint", package: "stoic")]
),
]
)In an Xcode project, add the Stoic package with the Lint trait, then add
StoicLint to the target's "Run Build Tool Plug-ins" phase. (The Xcode entry
point shares the SwiftPM command and is compiled only under
canImport(XcodeProjectPlugin); it has not been exercised against a real
project yet.)
The tool
stoic-lint [--baseline <file>] [--output <stamp>] <path>…
stoic-lint --write-baseline <file> <path>…
swift run --traits Lint stoic-lint Sources # in a checkout of StoicEach <path> is a Swift file or a directory searched for *.swift (hidden
directories are skipped). Exit status: 0 clean, 1 findings that fail the
build, 2 the tool could not run (bad arguments, unreadable file, the
trait-off stub).
Diagnostics
The format Xcode and SwiftPM both parse out of tool output:
/abs/path/File.swift:12:5: error: [stoic.blocking-in-async] `wait()` parks a thread of the cooperative pool inside async code; …
/abs/path/File.swift:12:5: note: use `AsyncSemaphore` / `AsyncMutex`, or `await` the work directly
stoic-lint: 1 error(s) in 1 file(s)Line is one-based, column is one-based in UTF-8 bytes (what Xcode counts).
The note: carries the replacement in words. A baselined finding is the same
line with warning: and no note.
Suppression
// stoic:allow(unchecked-sendable): every access to `value` goes through `lock`
final class Box: @unchecked Sendable { … }
let handle = try! open() // stoic:allow(force-try): created by the build step aboveThe comment goes on the offending line, or alone on the line above it. For a
declaration or statement that spans lines, the comment above its first line
covers the whole of it. stoic:allow(a, b): reason names several rules; both
force-try and stoic.force-try are accepted.
Baseline
A baseline file lists accepted findings. If .stoic-lint-baseline.json
exists at the package root (or the Xcode project's folder), the plugin passes
it as --baseline and declares it an input.
stoic-lint --write-baseline .stoic-lint-baseline.json Sources{
"entries": [
{
"file": "Sources/App/Legacy.swift",
"hash": "9ca4c22665d8892f",
"rule": "stoic.discarded-try"
}
],
"version": 1
}Semantics
Syntactic, on purpose. The linter parses; it does not type-check. It sees
semaphore.wait() and Task.detached, not that semaphore is a
DispatchSemaphore. So rules match the shape a pattern takes in practice and
prefer a missed finding to a false one: a finding is a build error, and an
error the author cannot trust trains people to suppress without reading. A
false positive is a bug in the rule; a false negative is a known limit,
written in the rule's row below.
Every rule is an error. There is no warning severity for a rule. The one
warning: is a finding the baseline accepted, which is "an error we agreed
to postpone", and it is reported so the debt stays visible.
Suppression needs a reason. A stoic:allow comment without a reason
(// stoic:allow(force-try), or an empty one after the colon) suppresses
nothing and is itself a finding, stoic.allow-without-reason, which is not
suppressible. So the original finding and the missing reason both show until
a reason is written. A comment on the line above covers the next line only if
it stands alone on its line; a trailing comment after code speaks for its own
line, so it cannot silently excuse whatever happens to follow. Only a comment
that starts with the marker counts, so documentation that mentions the
syntax is left alone, and a string literal containing it is not a comment.
What "inside async code" means. For blocking-in-async a body is async
when it is an async function, initializer or accessor; a method or computed
property of an actor (a blocked synchronous actor method blocks the actor's
executor; nonisolated and static members are exempt); a closure marked
async; or a closure passed to something that runs it as a task (Task,
Task.detached, group.addTask, withTaskGroup and its siblings,
MainActor.run, and Stoic's withTimeout, withDeadline, retry, recur,
withScope, withTaskScope, race, hedge, mapConcurrent,
withContext, withPermit). Any other closure is synchronous, even inside
an async function. That misses items.map { sleep(1) } in an async function,
and it is deliberate: DispatchQueue.global().async { semaphore.wait() }
blocks a Dispatch thread, not the cooperative pool, and is the sanctioned way
to bridge blocking code. The rule must not call the bridge a bug.
What "discarded" means. discarded-try flags try? as a statement
(try? save(), try? await sync()) and _ = try? f(). It does not flag a
used result (let x = try? f(), return try? f(), if let x = try? f(),
try? f() ?? d). A lone try? f() that is a body's value is also not
flagged: a closure, a function with a non-Void return, a getter, or an
if/switch expression in value position. The exception to the exception is
a Task { try? await f() } that is itself a statement, because nobody can
read that task's result.
Test code. force-try skips a file whose path contains /Tests/ or whose
name ends in Tests.swift: a crash in a test is a failing test. No other
rule has a test exemption. (A project that lives under a directory named
Tests is not linted by force-try at all; keep sources out of it.)
Build integration. The plugin creates one .buildCommand per target over
its Swift sources. The inputs are the sources (and the baseline); the one
declared output is a stamp file <Target>.stoic-lint-stamp in the plugin work
directory, which the tool writes only when the run passes. The build
system reruns the command when an input changes, and a failing run leaves no
stamp, so it reruns on every build until the findings are fixed; a failure
cannot go quiet by being cached. The command ordering relative to compilation
is the build system's: in the end-to-end run below both the compiler's and the
linter's errors appear in one build.
Baseline identity. An entry is (file path relative to the baseline's directory, rule, FNV-1a hash of the trimmed offending line). No line numbers:
adding a function above a baselined line moves it without changing its
identity, so the baseline does not churn on unrelated commits. Changing the
offending line itself makes it a new finding, which is the right moment to
look at it again. Identical lines are separate entries and each accepts one
finding: a copy-pasted third occurrence is new.
The count may only fall. Findings in the baseline are warnings; findings
not in it are errors. Fix one and its entry no longer matches: the build
passes and warns that the baseline has stale entries
([stoic.baseline], attached to the baseline file). Regenerate with
--write-baseline and the ratchet turns down. Nothing turns it up except
doing that regeneration on purpose, in a diff a reviewer sees. Entries for
files outside a run (another target's) are not reported as stale. The hash is
FNV-1a, not Swift's Hasher, which is seeded per process and would make a
baseline unreadable tomorrow.
Rules
All rules are errors. Fixes are advice in words, never an automatic rewrite: the right replacement depends on code the linter does not read.
| Id | Flags | Why | Instead |
|---|---|---|---|
stoic.blocking-in-async |
In async code (above): x.wait() / wait(timeout:) on a semaphore- or group-looking receiver, sleep(n), usleep(n) (also Darwin./Glibc.-qualified), Thread.sleep, queue.sync { } |
Parks a cooperative-pool thread; the pool is about one thread per core, so it starves or deadlocks under load. Not cancellable, not on the test clock | AsyncSemaphore / AsyncMutex; try await Ambient.sleep(for:); make the work async and await it |
stoic.unchecked-sendable |
@unchecked Sendable in any conformance, including extensions |
Switches the data-race check off by assertion | A real Sendable type, an actor, Mutex-guarded state; or justify with stoic:allow |
stoic.nonisolated-unsafe |
nonisolated(unsafe) on any declaration |
Exempts the declaration from data-race checking | Mutex or an actor; or justify with stoic:allow |
stoic.unbounded-stream |
AsyncStream / AsyncThrowingStream created with init or makeStream without bufferingPolicy:, or with .unbounded. unfolding: streams are exempt (pull-based) |
The default buffer is unbounded: a fast producer grows memory until the app is killed (principle 2) | Channel(capacity:overflow:), or bufferingPolicy: .bufferingNewest(n) |
stoic.discarded-try |
try? whose result is discarded: a statement, or _ = try? … |
The error, its cause and what the code was doing vanish; nothing records that it failed | Handle the error, or let it propagate through withContext("what I was doing") { … } |
stoic.force-try |
try! outside test code. Exempt: Regex("literal") and NSRegularExpression(pattern: "literal") (a constant pattern fails every time or never) |
A crash with no message and no context is the failure handler | Propagate with try, or handle; if it cannot fail, stoic:allow(force-try): why |
stoic.task-detached |
Task.detached (also Task<A, B>.detached) |
Loses task-locals: the ambient context, deadlines, cancellation causes. It runs on the wall clock with no bound | Task {}, or a structured child (withTaskGroup, withTaskScope) |
stoic.sleep-for-timeout |
Any .asyncAfter( call |
A timer no scope owns, no deadline bounds, and a test clock cannot advance | withTimeout to bound work; Ambient.sleep(for:) inside a task the caller owns. Justification is allowed |
stoic.allow-without-reason |
A stoic:allow(…) comment with no reason (or malformed). Not suppressible |
"Allow" typed as a reflex defeats the rule | // stoic:allow(rule): why this is safe here |
Known limits, all false negatives by design:
blocking-in-asyncdoes not see a plain closure inside an async function (the bridge case), a closure whose async-ness comes from its inferred type (let work: () async -> Void = { sleep(1) }), or a receiver whose name does not read like a semaphore or group (let latch = DispatchSemaphore(…)thenlatch.wait()is missed;sem,semaphore,…GroupandDispatchSemaphore(…)are caught).queue.syncneeds "queue" in the receiver's spelling.unbounded-streamtrusts abufferingPolicy:passed as a variable.discarded-trydoes not followtry?through a closure's inferredVoidreturn.
Failure modes
| What happens if… | Behaviour |
|---|---|
| a source file has syntax errors | SwiftParser recovers; the rules run on what parsed. The compiler reports the syntax error, not the linter |
the plugin is attached but the Lint trait is off |
The tool is a stub: prints "built without the Lint trait", exits 2, the build fails with that message |
| the baseline file is missing or invalid | Exit 2 with the reason; the build fails. A baseline that cannot be read must not silently become "no baseline" |
the baseline has a newer version |
Exit 2: "baseline version N is not supported" |
| a baselined finding is fixed | Build passes; warning [stoic.baseline] that entries are stale |
| a new finding appears with a baseline present | Error; baselined ones stay warnings |
| a baselined line is edited | New finding (its hash changed); the old entry goes stale |
| a suppression has no reason | Suppresses nothing; stoic.allow-without-reason plus the original finding |
| a suppression names an unknown rule | Suppresses nothing; the original finding remains |
| the tool fails after writing nothing | No stamp, so the next build runs it again |
| a target has no Swift sources | No command is created |
| a file is not valid UTF-8 | Decoded leniently (replacement characters); lines and columns of valid text are unaffected |
| the same file is passed twice, or a directory and a file in it | Linted once |
| a path does not exist | Exit 2 |
| a finding sits in generated code | Not special-cased; plugins receive the target's source files, and generated sources are the generator's business |
Packaging and the Lint trait
SwiftPM ignores the warning-control settings of dependencies, so the plugin is how Stoic reaches an app's build. The plugin needs a Swift parser, which means swift-syntax, which is large and slow to build. A library that most users never lint must not make every user fetch it.
What was found, by building Stoic and two scratch consumers:
- A dependency used only by targets conditioned on a disabled trait is not
fetched.
swift buildin Stoic with no trait leaves.build/checkoutsempty and writes noPackage.resolved; a consumer that depends on Stoic withouttraits: ["Lint"]likewise. With--traits Lint, SwiftPM resolves swift-syntax 604.0.0 and builds it. - Targets cannot be conditioned on a trait; dependencies and build settings
can. SwiftPM builds every target of a package it builds, trait or no
trait. So
StoicLintCore,stoic-lintand the test target are always in the build graph.Package.swiftconditions their swift-syntax products on.when(traits: ["Lint"])and gives each aSTOIC_LINTdefine under the same condition (.define("STOIC_LINT", .when(traits: …)), aBuildSettingConditionthat accepts traits). Every source file is wrapped in#if STOIC_LINT, so with the trait off the library compiles to an empty module and the test target to zero tests. - The executable is a stub when the trait is off.
stoic-lintmust still link, so its#elsebranch is amainthat prints that it was built without the trait and exits 2. Attaching the plugin without the trait therefore fails the build with an explanation, not with a missing file. - Plugin targets and plugin products are unconditional. The
StoicLintplugin is in the manifest regardless of the trait; it only works when the tool behind it is real. - The default
swift buildandswift testof the Stoic repo do not need swift-syntax, and pass: 173 and 158 tests, andStoicLintCoreTestsreports 0 tests.swift test --traits Lintruns 89 more. - Traits are additive across the graph. If any package in an app's graph
enables
Linton Stoic, it is on for all of it. - Switching traits in one checkout rebuilds, and both configurations share
.build: thestoic-lintbinary there is whichever was built last. Run it withswift run --traits Lint stoic-lint …(the option goes before the product name; after it, it is passed to the tool and the stub runs). - A path dependency's identity is its directory name, so a consumer of a
worktree writes
package: "stoic-lint", not"stoic". From a URL it isstoic.
Stoic does not attach the plugin to its own targets: that would put swift-syntax in every build of the repo. Its own sources are checked by running the tool (below), and CI runs it as a step.
Testing
Pure rules are tested from inline snippets, with Swift Testing, trait-gated
like the target: swift test --traits Lint --filter StoicLintCoreTests.
- Per rule, positive and negative cases. Including: a synchronous function
calling
semaphore.wait()is not flagged; an async closure inside a synchronous function is; actor methods are,nonisolatedones are not;let x = try? f()is not flagged;try!in a/Tests/path is not;AsyncStream(bufferingPolicy: .bufferingNewest(1))is not. - Suppression. A reason suppresses; none does not and is reported; the qualified rule name works; a trailing comment does not speak for the next line; a justification above a multi-line declaration covers the clause; documentation and string literals that mention the marker are inert.
- Golden test of the diagnostic format, since Xcode and SwiftPM parse it, including the UTF-8 byte column.
- Baseline. JSON round trip and stable bytes; accepted findings are warnings and exit 0; a new finding fails; entries survive edits elsewhere in the file and indentation changes; editing the offending line is new; fixing leaves a stale entry and a warning; a duplicate is new; entries for files outside the run are not stale; pinned FNV-1a values so a baseline written today stays readable.
- Command line. Argument parsing, including bad usage.
- End to end. A scratch consumer package depends on the repository by path
with
traits: ["Lint"]and applies the plugin to a target full of violations.swift buildfails with eacherror: [stoic.…]line; after fixing or justifying each, it passes. With a baseline: passes with warnings; a new finding fails; a fix leaves the stale warning. With the trait off, the stub's message. (Reproduce with the manifest in the Usage section.) - Self-check.
swift run --traits Lint stoic-lint Sources Plugins Package.swiftis clean.
Non-goals
- Style. Formatting and naming are SwiftLint's and swift-format's.
- Type-aware rules. Anything that needs the type checker (is this
receiver really a
DispatchSemaphore?) belongs to the compiler's own diagnostics, or to a future rule built on index data. The linter stays a parser. - Automatic fixes. Messages name the replacement; they do not rewrite.
The right
Channelcapacity is a decision. - Warnings. A rule that is not worth an error is not a rule.
- A way to turn a rule off globally. Suppression is per site, with a reason. To adopt a rule gradually, use the baseline.
- Linting dependencies. The plugin sees the sources of the target it is attached to.
Planned
- A rule per primitive as it lands: a bare
Task.sleepin code under test (useAmbient.sleep), awhile trueloop that catches and sleeps (Supervisor), acatch { print(error) }(Report), a retry loop written by hand. @preconcurrencywithout a reason, named in AGENTS.md rule 7 and not yet checked.- A
--formatoption for CI systems that want JSON or SARIF. stoic-doctor, which audits an app's manifest for layer (b) settings.- Exercising the Xcode entry point against a real project.