Cog for Swift: lint tooling
August 17, 2026
Cog ships coglint, a SwiftSyntax linter for Cog usage rules. It is an executable style guide: each rule enforces a written convention, and each error links to the matching DocC page.
References that start with “core” point to core design and its companion files. Other section links point into this file.
1. Why Cog ships a linter
Cog's API is small, but code can still use it in hard-to-read ways. Examples include calling refresh inside a view, setting initial state in App.init, or hiding several reads in a helper object. These patterns compile. A linter can catch them during every build.
Two rules limit the linter's scope:
- It enforces only settled, written Cog conventions. The design stays the source of truth.
- It does not repeat compiler checks. Compile errors stay in
swift/CompileFail/; the linter checks valid Swift that breaks Cog style.
2. Why this tool shape fits
coglint is a standalone SwiftSyntax executable. Consumers download a native binary and run it through SwiftPM plugins, a script, or the command line.
Other choices do not fit:
- SwiftLint has no native third-party rule system. Its custom rules use regex.
swift formathas no third-party rules.- A macro sees only code attached to that macro. It also adds swift-syntax to a consumer's dependency graph.
- SourceKit and IndexStoreDB can add type data, but they need compiler settings or a finished build. They remain possible upgrades, not v1 needs.
SwiftPM build-tool plugins require a binary tool for a prebuild command. The plugin runs the binary on each build and prints normal path:line:column: error: messages for Xcode. A command plugin supports on-demand and CI use.
2.1 Syntax-only analysis
Syntax is enough for the first rules because Cog code has clear written forms:
- declarations name
Cog,CogBox, their nestedManual,Async, orProjectionshapes, or use.readOnly; - graph reads use the usual
cogsorcreceiver; and - views write
View,some View, and@Environment(\.cogs)in source.
A typealias, factory, or conformance declared in another file can hide this evidence. The linter reports only syntax it can identify with high confidence. Each rule fixture shows these accepted misses.
Android has a better delivery path: a Compose library can place lint rules in its AAR with lintPublish. Kotlin should use that platform feature instead of copying this Swift package design.
3. Architecture
3.1 Development package and products
The source lives in the separate swift/Lint SwiftPM package. Its swift-syntax and swift-argument-parser dependencies cannot enter Cog's root package graph.
| Product | Use |
|---|---|
coglint | CLI for scripts, hooks, mise, and CI |
CogLintBuildToolPlugin | Runs the linter during SwiftPM and Xcode builds |
CogLintCommandPlugin | Provides swift package coglint |
CogLintBinary | Native executable in CogLintBinary.artifactbundle.zip |
The CLI supports Xcode, GitHub, and SARIF reporters. The build plugin uses its work directory as a cache so unchanged files are cheap to check.
In this repo, mise run lint:swift lints library, Storefront, example, and test sources with the correct target role. Weather and TodoMVC are the feature-sized dogfood apps.
3.2 Distribution and releases
SwiftPM cannot resolve the nested swift/Lint package from a Git URL. The generated sibling repo, skeswa/coglint-plugins, therefore publishes the CogLintPlugins package. Only users who add that package fetch the binary. Normal Cog users keep a source-only, dependency-free root package.
Cog and coglint use the same version. Source, tests, fixtures, and DocC pages stay in this repo. The sibling manifest points to the matching immutable Cog release asset and checksum. It has no independent version line.
The Actions-only release order is:
- Build and test both native
coglintvariants from the exact Cog release candidate. - Publish the Cog tag, release, binary archive, checksum, provenance, and docs.
- Generate and publish the sibling package at the same version.
- Build a clean consumer from the public sibling tag.
See the release runbook for the full checks and approval gates.
3.3 Diagnostics, suppressions, and fixtures
A finding uses the compiler's line format and includes its rule and DocC URL:
WeatherCard.swift:186:7: error: [primitives-only-in-ops] `refresh` is a demand
on the graph; call a named op from a `CogOps` extension —
https://skeswa.github.io/cog/documentation/cog/primitivesonlyinopsAll v1 findings are errors. Suppress one finding on the next physical line with a reason:
// coglint:disable-next-line <rule> -- <non-empty reason>A bad directive suppresses nothing. There are no global severity settings.
Each rule owns triggering fixtures with exact positions and non-triggering fixtures for valid code and known syntax-only misses. The same fixtures build its DocC article. The enabled rule set and the fixture inventory are compared by slug, and the documentation suite requires both DocC topic lists to link every article, so an enabled rule cannot ship unspecified or unreachable. The linter runs its own tests before checking another target.
4. The rules
A shared classifier finds four kinds of syntax:
- Cog declaration: written type or initializer names a Cog declaration type, or
.readOnlyprojects a known declaration. It handles module names, generic arguments, optionals, and.init. - View: a type writes
Viewconformance or hasbody: some View. - Graph receiver:
@Environment(\.cogs), a selector or turn parameter namedc, a mechanism controller, or a local directly returned byCogs.assemble. - App entry: a type writes
Appconformance.
The classifier does not follow assignments or infer across files.
| Rule | Required form |
|---|---|
cog-declaration-suffix | Keyless names end in Cog; box names end in Cogs |
no-cogs-in-view-init | Views read Cogs from the environment |
primitives-only-in-ops | App code calls turn, refresh, and discard only inside CogOps |
initial-state-in-mechanism | App assembly does no graph work |
manual-cog-private | Writable sources are private or fileprivate |
manual-cog-underscore | Sources begin with _; projections drop the underscore |
no-multi-read-cogs-helper | Reads stay flat instead of hiding in a runtime helper |
tracked-binding-adapters | Graph bindings are tracked adapters on Cogs |
4.1 cog-declaration-suffix
A keyless Cog, Cog<Value>.Manual, Cog<Value>.Async, or projection name must end in Cog. A CogBox, CogBox<Value, Key>.Manual, CogBox<Value, Key>.Async, or box projection must end in Cogs. Put qualifiers before that suffix.
The classifier reads both the written type and initializer. It does not follow a declaration copied into a debug seed target.
4.2 no-cogs-in-view-init
A recognized view must not store Cogs or accept it in an initializer or method parameter. This includes optionals and generic argument positions. Use:
@Environment(\.cogs) private var cogsA View conformance written in another file is a known miss.
4.3 primitives-only-in-ops
App code may call turn(...), refresh(...), or discard(...) only as a bare call inside an extension CogOps. Calls on a known graph receiver fail everywhere else. Bare or self. calls inside extension Cogs also fail.
Each of those names a runtime mechanic rather than a domain intent: publish a turn, demand a fresh generation, release a state the app is finished with. The rule is syntax-only, so it checks where they are written, never whether the release or receipt they express is correct.
Tests may call primitives directly under their test-role exemption. A nested writer turn inside a CogOps method remains valid.
4.4 initial-state-in-mechanism
An App initializer may bind the result of Cogs.assemble(...) and retain it. It must not read from it or call a primitive, op, or helper before retention. Put initial state in a supplied mechanism; operate settles before assembly returns.
Service and mechanism setup before assembly is valid. Direct retention without a local is also valid. A factory that hides assembly is a known syntax-only miss.
4.5 manual-cog-private
Each Cog<Value>.Manual and CogBox<Value, Key>.Manual declaration must be private or fileprivate. Expose .readOnly or an automatic cog instead of the source.
Both access words are valid. At file scope they mean the same thing, and swift format already chooses its preferred spelling.
4.6 no-multi-read-cogs-helper
A value-returning member of extension Cogs or extension CogOps fails when its own body contains two or more graph reads. The rule counts value, status, and peek reads, but not reads inside nested closures.
Members with no return value, or a written View, some View, or Binding return type, are outside the rule. The rule does not trace locals through later assignments.
Declare a true computed value as an automatic cog. Otherwise, read each value on its own line at the call site.
4.7 manual-cog-underscore
Each Cog<Value>.Manual and CogBox<Value, Key>.Manual declaration name must begin with _. A .readOnly projection of a recognized source must be named exactly its source's name without the leading underscore, so the projection owns the clean domain name. An underscored source that is never projected is accepted.
The pairing check uses the projected base identifier the shared classifier resolved. An annotation-only projection names no source and stays silent, the same syntax-only boundary as every other classifier evasion.
4.8 tracked-binding-adapters
An explicit Binding(get:set:) construction is checked by where it sits. A construction inside a recognized view fails when any of its closures mentions a classified graph receiver: a binding that reaches the runtime is a writable surface, and those belong in the rig's +Bindings.swift adapters. Every other construction fails when its get: closure reads through peek, which registers no dependency and leaves the control showing a value it has stopped following.
The two checks divide by placement, so one construction never reports both. A view binding is a placement finding; fixing it moves the construction where the tracking check applies.
Setter peeks are outside the rule, because only the getter must register. So is the setter's write: primitives-only-in-ops already rejects a turn or refresh inside an adapter, so this rule does not repeat that check. A Binding returned by a factory, or built in a type whose View conformance is written in another file, names no evidence this pass can trust.
5. V1 limits
- No type data. IndexStoreDB is the planned path if cross-file misses become a real problem.
- No autocorrect.
--fixneeds its own rule and fixture contracts. - No general Swift style.
swift formatowns that work. - No duplicate compiler rules.
- No SwiftLint regex subset. A second rule surface would drift.
6. Use and release
The eight rules, all reporters, both plugins, the CLI, DocC pages, artifact tests, and sibling distribution are implemented. Each rule landed with fixtures and the same examples in its docs.
The package uses Swift tools 6.2 and Swift 6 mode. Release builds use Xcode 26.6 (17F113) and Swift 6.3.3. The exact pins are:
| Item | Pin |
|---|---|
swift-syntax | 603.0.2, revision 79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1 |
swift-argument-parser | 1.8.2, revision 6a52f3251125d74daf04fcbd5e6f08a75d074382 |
| macOS target | 14.0 |
| Apple Silicon variant | arm64-apple-macosx14.0 |
| Intel variant | x86_64-apple-macosx14.0 |
Consumers receive native binaries, so these source dependencies do not need to match the consumer's Swift compiler.
7. Fixed choices and open work
Names
Users type coglint. SwiftPM uses role-specific names: CogLintBuildToolPlugin, CogLintCommandPlugin, CogLintBinary, and CogLintPlugins. The artifact is CogLintBinary.artifactbundle.zip.
Permanent rule URLs
| Rule | URL |
|---|---|
cog-declaration-suffix | /cog/documentation/cog/cogdeclarationsuffix |
no-cogs-in-view-init | /cog/documentation/cog/nocogsinviewinit |
primitives-only-in-ops | /cog/documentation/cog/primitivesonlyinops |
initial-state-in-mechanism | /cog/documentation/cog/initialstateinmechanism |
manual-cog-private | /cog/documentation/cog/manualcogprivate |
manual-cog-underscore | /cog/documentation/cog/manualcogunderscore |
no-multi-read-cogs-helper | /cog/documentation/cog/nomultireadcogshelper |
tracked-binding-adapters | /cog/documentation/cog/trackedbindingadapters |
Each path is under https://skeswa.github.io. The docs test checks both the HTML route and data file. GitHub Pages cannot use DocC redirect metadata as an HTTP redirect, so a future move must ship a real redirect first.
Why the sibling package is required
A measured fixture put the binary target in Cog's root manifest but did not apply either plugin. Both SwiftPM and Xcode still fetched the binary:
| Test | Result |
|---|---|
| SwiftPM resolve | Tried the unused URL and failed after 0.68 seconds |
| SwiftPM release build | Required the unused binary and failed after 1.6 seconds |
| Xcode workspace build | Failed package resolution after 1.9 seconds |
The two probe binaries already totaled 39,104,024 bytes before bundling. Keeping the binary in the root package would charge every Cog user for an unused tool and could break a normal resolve. The sibling package avoids that.
Open work
Possible later rules cover local names after reads, per-view environment use, fatalError, explicit class deinits, and @testable import Cog in scenario tests. Type-aware analysis, autocorrect, a SwiftLint subset, and Kotlin lint timing also remain open.