Static ARC analysis for Swift, built on swift-syntax. Analysis is source-level: no build, no index, no project file required.
Three families of memory bugs:
- Retain cycles — closures stored on
selfcapturingselfstrongly, Combinesink/assign(to:on:)cancellables stored onself, non-terminatingTasks whose handle lives onself. - Anchor leaks — immortal framework objects keeping yours alive: repeating
Timer/CADisplayLinktargets and blocks, block-basedNotificationCenterobservers,URLSessiondelegates. Cleanup that exists only indeinitis a definite leak —deinitcannot run while the anchor holdsself. - Premature releases — the inverse bug: lifetime tokens (
AnyCancellable,NSKeyValueObservation, observer handles) discarded or bound to storage that dies at scope end, silently stopping the work they own.
What arcleak deliberately does not flag: transient escapes (completion
handlers, finite Tasks — deferred deallocation, not leaks), non-escaping
closures, value-type self (SwiftUI bodies), the same-lifetime
[unowned self] pattern, sinks over provably finite pipelines, SwiftData
@Model relationships (macro-managed storage), and tokens that are a
function's return value. A clean-fixture corpus gates false positives in CI.
Every retention contract the rules encode is runtime-proven: the leak oracle
(Scripts/run-leak-oracle.sh) reconstructs each one and verifies it with
leaks -atExit and deinit canaries on every macOS CI run.
Swift 6.4 toolchain, macOS 15+ or Linux. No released toolchain ships tools
6.4 yet; the repo pins a swiftly snapshot via .swift-version (build with
swiftly run swift build), and CI runs nightly toolchains.
Platform floor. The floor is macOS 15 (raised from 14 in 0.3.0): the opt-in
--index-storebackend links IndexStoreDB, whose target floor is macOS 15. arcleak is a developer tool, so the bump is deliberate and acceptable. The default syntax-only analysis is unaffected, and Linux builds gate the index out entirely (#if canImport(IndexStoreDB)) with no behavior change.
arcleak analyze Sources # xcode-format diagnostics, exit 1 on errors
arcleak analyze --format json . # machine-readable report (also: --format sarif)
arcleak analyze --strict Sources # exit 1 on any finding
arcleak analyze --fix Sources # apply [weak self] fix-its (--fix-dry-run to preview)
arcleak analyze --index-store . # resolve cross-module types via the index (macOS-only)
arcleak rules # list rules, severities, suppression syntax
arcleak rules timer-retains-self # one rule's retention contract and fix
arcleak lsp # LSP server over stdio: diagnostics + accept quick-fixExit codes: 0 clean or warnings-only, 1 error-severity findings (or any
finding with --strict), 64+ usage/configuration failures.
Baseline — adopt on a legacy codebase by accepting current debt and gating only new findings:
arcleak analyze Sources --write-baseline .arcleak-baseline.json
arcleak analyze Sources --baseline .arcleak-baseline.json # CI: only new bugs failBaseline fingerprints include positions, so large refactors shift findings out of the baseline — regenerate deliberately rather than let fuzzy matching swallow new bugs.
Cache — on by default (~/Library/Caches/arcleak/facts.json; override
with --cache-path, disable with --no-cache). Only parsed facts are
cached, keyed by content fingerprint and tool version; rules always re-run,
so findings never go stale relative to rules or configuration. The cache
fails open: corrupt or mismatched caches behave as empty.
By default arcleak reasons only about types declared in the analyzed corpus —
a stored property whose type lives in another module or the SDK is skipped as
unknown. --index-store consults the IndexStoreDB index that SourceKit-LSP /
swift build already produce to resolve those types' class-ness, so a strong
stored property pointing at a class in another module can complete a
cross-module mutual-strong-properties cycle:
arcleak analyze Sources --index-store # discover the store (.build/…, DerivedData)
arcleak analyze Sources --index-store-path .build/index/store # explicit store
arcleak analyze Sources --index-store-build # `swift build` one first if missingIt is opt-in, macOS-only, and always fails open. With no index, on Linux,
or when the store is stale relative to the analyzed sources, arcleak prints a
one-line note and runs the corpus-only analysis unchanged — byte-identical to
the default. Resolution is conservative: an edge is added only when the index
confirms a type is a class/actor, never on a guess. The dylib
(libIndexStore.dylib) is loaded from your active toolchain (a regular file
owned by root or you, not world-writable).
Groups findings of similar shape together in the report by embedding each finding's flagged-site line and clustering by cosine similarity. This is ordering only — it never changes which findings fire, their severity, or the exit code. Experimental and off by default.
Out of the box it uses Apple's on-device NLContextualEmbedding (zero
download, with a deterministic n-gram fallback when the system asset is
missing). Be aware of what that model is: an English natural-language
model. It reads source lines as prose, so it over-clusters — anything that
"looks like a statement" lands in one narrow cone of the vector space. A
code-trained sentence model groups noticeably tighter, and being far smaller,
runs several times faster.
So --embedding-bundle takes a directory holding a Core ML model plus the
WordPiece vocabulary it was trained with — a .mlpackage (compiled on first
use) or .mlmodelc, alongside vocab.txt / tokenizer.json / config.json.
WordPiece (BERT-family) bundles, e.g. all-MiniLM-L6-v2. arcleak tokenizes
in-house (WordPieceTokenizer, pinned token-for-token against
swift-transformers before that dependency was dropped) rather than linking a
9-package HuggingFace stack into every binary. A BPE or SentencePiece bundle
fails to load and falls back to the zero-download provider rather than
tokenizing wrongly.
Pick a sentence-embedding model, not a masked-LM checkpoint. "Code-trained" is not the property that matters — cosine-comparability is. Byte-level BPE support shipped briefly in v0.6.0 to allow CodeBERT, then was withdrawn once measured: on the same 29-finding corpus the anomaly-score spread was all-MiniLM-L6-v2 36-77% (stdev 10.96), Apple NLContextual 2-8% (stdev 1.76), and CodeBERT 1-3% (stdev 0.68) — the worst of the three. A masked-LM checkpoint produces anisotropic vectors: everything crowds into one narrow cone and every pair looks similar. MiniLM is contrastively fine-tuned for exactly this use:
arcleak analyze Sources --experimental-embedding-rank \
--embedding-bundle /path/to/Models/MiniLM
export ARCLEAK_EMBEDDING_BUNDLE=/path/to/Models/MiniLM # same, without the flagA build that ships a model next to the binary needs neither: Models/MiniLM
beside the executable, or share/arcleak/Models/MiniLM for a bin/+share/
install, is discovered automatically. Precedence is
--embedding-bundle → ARCLEAK_EMBEDDING_BUNDLE → executable-adjacent →
NLContextualEmbedding → deterministic fallback, and the run's status note
names the model that actually ran:
arcleak: experimental embedding-rank grouped 12 finding(s) by flagged-site similarity [provider: bundle:MiniLM]
A bundle that fails to load is reported and ignored — ranking falls back to
the default provider, and the finding set and exit code are untouched. The
flag has no effect without --experimental-embedding-rank, and none of this
exists on Linux (#if canImport(CoreML)), where the flag is accepted and
ranking is skipped with a note.
SwiftPM build-tool plugin — diagnostics appear inline in every build; error-severity findings fail the build:
// Package.swift
dependencies: [
.package(url: "https://github.com/g-cqd/arcleak.git", from: "0.1.0"),
],
targets: [
.target(
name: "App",
plugins: [.plugin(name: "ArcLeakBuildToolPlugin", package: "arcleak")]
),
]On demand: swift package arcleak [paths…]. For non-SwiftPM projects, an
Xcode run-script phase invoking arcleak analyze "${SRCROOT}/Sources" does
the same. A composite GitHub Action ships in action.yml (build, analyze,
SARIF upload to code scanning).
Directives use the @ sigil with the @al: or @arcleak: namespace
(synonyms). accept documents an intentional strong reference with an
auditable reason; it covers the flagged line and the next, so it works
trailing on the code or on the line above:
// @al:accept -- owner tears this down in shutdown(); lifetime is intentional
onTick = { self.tick() }Precise forms, optionally scoped to rule ids:
// @al:accept:this timer-retains-self -- invalidated by the scene lifecycle
// @al:accept:next stored-closure-strong-self
// @al:disable combine-sink-self-cycle
…
// @al:enable combine-sink-self-cycleAccepted findings are not dropped: they are counted in the summary and
carried, with reasons, in the JSON report. A directive naming only unknown
rule ids accepts nothing. Test fixtures use a separate # sigil for runner
expectations (// #al:expect <rule>), so assertions and directives cannot
collide.
{
"rules": {
"task-nonterminating-self": { "severity": "error" },
"token-stored-in-local": { "enabled": false }
},
"exclude": ["Generated/", "Tests/Fixtures/"]
}Read from --config or ./.arcleak.json. Unknown rule ids are a hard error.
The definite-leak upgrade (cleanup only reachable from an unreachable
deinit) reports as error regardless of configured severity; use a
directive if it is truly intentional.
arcleak's knowledge base keys on literal API shapes (.sink, .assign,
Timer, …). Codebases that wrap those APIs in a helper hide the shape from
the matcher — a hand-rolled React.to, a bind(...), a Reactor type. Declare
the wrapper and arcleak sees through the indirection. Contracts are opt-in and
fail closed: a malformed contract is a typed error, and without a contract
arcleak stays silent rather than guess at a wrapper it cannot prove.
{
"rules": {},
"exclude": [],
"contracts": [
{
"callee": "to",
"base": "React",
"template": "sinkWrapper",
"tokenName": "the React.to subscription"
}
]
}Fields: callee (required — the method name, to in React.to(…)), base
(optional receiver, React for the static call), requiredLabels (optional —
all must be present to match), tokenName (optional diagnostic name), and
template:
tokenProducer— the call returns a lifetime token that must be owned. Feeds the premature-release rules: a discarded or scope-local token is flagged exactly like a droppedAnyCancellable.sinkWrapper— a custom Combine wrapper that wraps.sink/.assign. A superset oftokenProducer: the returned token still feeds premature-release, and the wrapper's closure is analyzed for strong-selfcapture. When the token is stored onselfand the closure capturesselfstrongly, arcleak reportscombine-sink-self-cycle— the same cycle it catches for a literal.sink. Because the real upstream is hidden inside the wrapper, completion is unknown, so the finding is awarningwith an explicit "completion could not be determined" note rather than an over-claimederror.
With the contract above, becomeInactiveTask = React.to(.willResignActive) { _ in self.disconnect() } (the AnyCancellable stored on self, the closure
capturing self strongly) is flagged; the same call with [weak self] is not.
- Default (syntax, corpus-only) — best for CI gating. With no
configuration, arcleak is high-precision and produces zero false positives
on clean code (verified: 0 findings across Luce, Kyklos, LotoBuddy, and
Stations). It catches direct
.sink/.assign,Timer/CADisplayLink, block-basedNotificationCenter,URLSessiondelegates, non-terminatingTasks, and stored-closure/bound-methodself-cycles. Gate CI on this. - Custom Combine wrappers — add a
sinkWrappercontract. If your codebase wraps.sinkin a helper (React.to,bind, aReactor), declare it as asinkWrappercontract (see the exact config above) so arcleak sees through the indirection and flags strong-selfcycles hidden by the wrapper. Without the contract arcleak conservatively stays silent rather than invent a cycle it cannot prove — so a wrapper-heavy codebase reads as clean until you teach it the wrapper. - Cross-module types — enable
--index-store(macOS). For retain cycles that close through types declared in other modules or the SDK,--index-storeresolves their class-ness from the build index (see above). It is opt-in and fails open: with no index, on Linux, or against a stale store it runs the corpus-only analysis unchanged. - True-negative discipline. arcleak reports nothing it cannot prove, so
silence means "no provable cycle in what I can see," not "no cycles." A
wrapper it does not know, a cycle that closes through an unresolved module, or
storage created by a macro can all hide a real leak. Pair arcleak with
Instruments /
leaksfor runtime ground truth on wrapper-heavy code.
| id | default | detects |
|---|---|---|
stored-closure-strong-self |
error | closure stored on self (property, lazy initializer, member collection) capturing self strongly |
combine-sink-self-cycle |
error | strong-self sink whose AnyCancellable is stored on self — including self captured only to feed a nested closure's [weak self] |
combine-assign-self-cycle |
error | assign(to:on: self) with the cancellable stored on self |
timer-retains-self |
warning† | repeating Timer/CADisplayLink retaining self with no reachable invalidate() |
notification-observer-leak |
warning† | strong-self block observer; the center holds block and token until removal |
task-nonterminating-self |
warning† | while true/for await Task capturing self strongly |
unstored-lifetime-token |
warning | lifetime token discarded — from framework APIs or same-file functions whose return type is a token |
token-stored-in-local |
warning | token bound to storage that dies at scope end, or to a local captured by an escaping closure (never removed → unbounded growth) |
mutual-strong-properties |
warning | cross-file ownership-graph cycle: strong stored properties whose types point at each other, reported with the retention path |
urlsession-delegate-leak |
warning† | URLSession(configuration:delegate: self, …) with no reachable invalidation |
dispatch-source-cycle |
error | dispatch source stored on self whose handler captures self strongly, no reachable cancel() |
unowned-outlives-owner |
warning | [unowned self] in an anchor-held closure — traps if self deallocates first |
dead-weak-capture |
warning, opt-in | [weak self] whose body never uses self |
delegate-strong-property |
warning, opt-in | strong stored property named/typed like a delegate |
Bound method references (handler = self.method, sink(receiveValue: handle)) and self-referencing local functions passed as values feed the same
capture pipeline as closures.
† upgraded to error when the only release call sits in deinit (or the
task handle is stored on self) — the release is unreachable by construction.
Cross-file cycle detection covers the analyzed corpus: every file passed in
one run. Types, weak-ness, and class-ness declared in other modules and the
SDK are outside it by default — the opt-in macOS --index-store backend
resolves class-ness for those (see above); release-call reachability across
files remains corpus-only.
mutual-strong-properties is type-level: an SCC proves the types can
cycle; verify instance ownership before restructuring. Analysis is of
written source — macro-generated storage is invisible (SwiftData @Model is
special-cased).
Two indirection gaps are known and deliberate rather than silently wrong.
Custom .sink/.assign wrappers hide the API shape from the matcher —
opt in with a sinkWrapper contract (above) so arcleak analyzes the wrapper's
closure. A bound method buried in a collection literal handed to a
constructor whose result is stored on self (self.router = Router([.k: self.method]) — the Stations PlaybackService/Reactor shape) is not
tracked: proving the cycle needs the constructed type's storage semantics
(does it retain the collection, and does the work it feeds outlive the owner?),
which is cross-type ownership arcleak does not model — flagging it
unconditionally would false-positive on the many constructors that consume
closures transiently. Direct bound-method storage (self.handler = self.method) and bound methods handed straight to a token API
(sink(receiveValue: handle)) are caught.
Silence is not proof of absence; confirm with Instruments/leaks for runtime
ground truth.
The corpus is always everything; only the report is scoped. Passing a
pull request's changed files as the input produces a different and wrong
answer, because these are whole-program analyses — mutual-strong-properties walks an ownership graph built from every type in the corpus, and a subset run also prunes the shared facts cache down to the subset.
git diff --name-only origin/main... -- '*.swift' > changed.txt
arcleak analyze . --only-from changed.txt --baseline .arcleak-baseline.json --strict--only (repeatable) and --only-from <file|-> scope the report. Findings
outside the scope are kept on outOfScope and counted in the summary, so a
scoped run can never be mistaken for a clean one. An empty scope reports
nothing — a pull request that changed no Swift is not a licence to report the
whole repository.
Finding.fingerprint — what --baseline matches and what SARIF exports as
partialFingerprints — hashes the path relative to the repository root,
found by walking up for .git. So a baseline committed to the repository
matches on any machine, and it does not matter whether the corpus was named as
a directory or as an explicit file list, or where the checkout lives.
--relative-to <dir> additionally changes the paths that are displayed (and
re-anchors fingerprints to that directory, which from the repository root is
the same anchor). SARIF uris must be repository-relative for code scanning to
link them, so pass it when uploading SARIF.
| Code | Meaning |
|---|---|
0 |
no findings |
1 |
findings — and nothing else |
64 |
usage error (a path that does not exist) |
70 |
internal failure, including a run cancelled before the corpus was complete |
78 |
invalid configuration |
1 means findings only, so a step that posts a review comment on 1 will
not fire on a typo in the config file. A cancelled run reports no findings
and exits 70 rather than looking clean: a whole-program analysis over a
partial corpus does not report less, it reports wrongly.
MIT — see LICENSE.