Note: swift-linter currently depends on a package that has not yet been published; it will become externally buildable when that dependency's repository goes public with the ongoing incremental release.
SwiftSyntax-based AST linter for Swift packages. Hosts rules whose predicates require an abstract syntax tree — typed-system escape patterns, ownership-discipline violations, spec-mirror conformances — that cannot be expressed as a regex on source text.
Run the linter against any Swift package directory:
swift run swift-linter /path/to/your-packageThe engine ships rule-pack-agnostic — without an explicit configuration,
zero rules fire. To activate a rule set, drop a Lint/ nested SwiftPM
package at your package root (see Adopting the Lint/ shape below).
Output is SwiftLint-compatible textual lines by default; --format sarif
emits SARIF 2.1.0 JSON suitable for CI artifact upload.
dependencies: [
.package(url: "https://github.com/swift-foundations/swift-linter.git", branch: "main"),
]Pre-1.0: no version tags yet. APIs may change; pin a commit for reproducible builds.
.target(
name: "YourTarget",
dependencies: [
.product(name: "Linter", package: "swift-linter"),
]
)The swift-linter executable ships as a separate product of the same
package. For ad-hoc invocation,
swift run --package-path <this-package> swift-linter <target> works
out of the box.
swift-linter is not a replacement for either tool; the three differ in
posture, not in capability ceiling. All three operate over Swift source
and all three reach AST-shaped rules — the question is whether the AST
path is the primary invocation or an opt-in mode alongside something
else.
| Tool | Posture | Primary mechanism |
|---|---|---|
| swift-format | Formatter with a lint subcommand |
SwiftSyntax-based formatting + style rules; ships ~43 rules covering indentation, brace placement, ordered members, doc-comment shape, and structural smells |
| SwiftLint | Style/convention linter; rules are predominantly SwiftSyntax-based, with an opt-in analyze command that adds SourceKit-backed type-information rules |
SwiftSyntax for the common case; SourceKit-LSP via swiftlint analyze when type information is required |
| swift-linter (this package) | AST-only by construction; AST predicates ARE the surface | SwiftSyntax + SwiftParser; no SourceKit-LSP dependency in the chain |
Use all three together: swift-format for style normalization, SwiftLint
for the broad style/convention pack (with swiftlint analyze for
type-information rules), swift-linter for the cases where the rule's
identity is the AST predicate itself — typed-system escape patterns,
ownership-discipline checks, spec-mirror conformances.
swift-linter detects two configuration shapes at the consumer's
package root. Most consumers should adopt Lint.swift; Lint/ is
the advanced shape, reserved for cases the single-file shape cannot
express.
-
Lint.swiftsingle file (recommended for most consumers) — a single Swift file at the package root declaring tools-version, rule-pack dependencies, and the active rule set viaLint.run(dependencies:) { ... }. Covers the common case of activating an institute-published bundle (Lint.Rule.Bundle.universal/.institute/.primitives), optionally narrowed via.excluding(rules:)for brand-owner subtractions. No nested SwiftPM resolution; one file, one parse. -
Lint/nested SwiftPM package (advanced) — aLint/Package.swift+Lint/Sources/Lint/main.swiftpair that imports rule packages and instantiatesLint.Configurationdirectly via the result-builder DSL. Required when the consumer needs in-house custom rules (arbitrary Swift code defining newLint.Ruleinstances), third-party rule packs not declared in any institute bundle, or per-rule programmatic configuration with constructor calls that take consumer-side domain values.
Both shapes produce the same Lint.Configuration at runtime — see
Internal model.
Create a Lint.swift file at your package root, alongside Package.swift:
your-package/
├── Package.swift
├── Lint.swift ← here
└── Sources/...
The file declares the tools-version directive, imports the rule pack(s)
the activated bundle pulls in, and calls Lint.run(dependencies:) { ... }
with the activated bundle in the trailing closure:
// swift-linter-tools-version: 0.1
// (Apache-2.0 license header)
import Linter
import Linter_Institute_Rules
Lint.run(dependencies: [
.package(
url: "https://github.com/swift-foundations/swift-institute-linter-rules.git",
branch: "main",
products: ["Linter Institute Rules"]
),
]) {
Lint.Rule.Bundle.institute
}Tools-version directive (// swift-linter-tools-version: 0.1) MUST
be the first line — it informs the engine which DSL version the file
targets, mirroring SwiftPM's swift-tools-version discipline.
Bundle selection: activate the bundle matching your package's layer
in the five-layer architecture — Lint.Rule.Bundle.universal for
universal Swift code, .institute for L2/L3 standards and foundations,
.primitives for L1 primitives. The bundles compose additively
(institute = universal + institute-pack; primitives = institute + primitives-pack), so activating a higher-tier bundle transitively
activates the lower tiers' rules.
Brand-owner exclusions: brand-owner packages (those whose primary
export is a typed primitive whose rules target external consumers'
access to the brand) narrow the bundle via .excluding(rules:):
Lint.Rule.Bundle.primitives.excluding(rules: [
Lint.Rule.`raw value access`.id,
Lint.Rule.`unchecked call site`.id,
// ...
])Each rule referenced by .id requires its declaring module directly
imported under Swift 6.3+ MemberImportVisibility (SE-0444). Each
exclusion SHOULD carry an in-file comment naming the brand-boundary
site that justifies it.
Invocation: swift run swift-linter . from your package root.
Adopt the nested-package shape ONLY when one of these triggers applies:
| Trigger | Why Lint.swift cannot express it |
|---|---|
In-house custom rules (Swift code defining new Lint.Rule instances) |
Custom rules need a SwiftPM compilation unit; a single-file Lint.swift parses but does not compile arbitrary rule code |
| Third-party rule packs not declared in any institute bundle | Activating a non-institute rule pack requires declaring it as a SwiftPM dependency and importing its module — needs Package.swift |
| Per-rule programmatic configuration with constructor calls | The Lint.Configuration { Lint.Rule.Configuration.enable(...) } DSL accepts rule constructor calls with consumer-side domain values; the bundle DSL is metatype-driven |
If none of these triggers apply, use Lint.swift instead.
Create a Lint/ directory at your package root with the following layout:
your-package/
├── Package.swift
├── Sources/...
└── Lint/
├── Package.swift
└── Sources/Lint/main.swift
Lint/Package.swift depends on the rule packs you want active:
// Lint/Package.swift
let package = Package(
name: "Lint",
products: [.executable(name: "Lint", targets: ["Lint"])],
dependencies: [
.package(url: "https://github.com/swift-foundations/swift-linter.git", branch: "main"),
.package(url: "https://github.com/swift-foundations/swift-linter-rules.git", branch: "main"),
],
targets: [
.executableTarget(
name: "Lint",
dependencies: [
.product(name: "Linter", package: "swift-linter"),
.product(name: "Linter Rule Unchecked", package: "swift-linter-rules"),
.product(name: "Linter Rule Cardinal", package: "swift-linter-rules"),
]
),
]
)Lint/Sources/Lint/main.swift activates the imported rules through a
Lint.Configuration result-builder, then runs the linter against the
consumer's source tree:
// Lint/Sources/Lint/main.swift
import File_System
import Linter
import Linter_Reporter_Text
import Linter_Rule_Unchecked
import Linter_Rule_Cardinal
import Terminal_Primitives
let configuration = Lint.Configuration {
Lint.Rule.Configuration.enable(Lint.Rule.Unchecked.self)
Lint.Rule.Configuration.enable(Lint.Rule.Cardinal.Count.self)
}
let arguments = Swift.CommandLine.arguments
let pathStrings: [Swift.String] = arguments.count >= 2
? [Swift.String](arguments.dropFirst())
: ["."]
do {
let consumerPaths: [File.Path] = try pathStrings.map { try File.Path($0) }
let findings = try Lint.Run.run(paths: consumerPaths, configuration: configuration)
Lint.Reporter.Text.emit(findings: findings, to: Terminal.Stream.stdout.write)
} catch {
print("[Lint] error: \(error)")
}Rules are activated by metatype reference (Lint.Rule.Unchecked.self),
not by string identifier — the engine resolves identity through .self
and propagates the typed metatype through the configuration. The
result-builder's top-level position requires the fully-qualified
Lint.Rule.Configuration.enable(...) form (the builder declares
multiple buildExpression overloads, so leading-dot inference is
ambiguous in the unconstrained position; inside if/for bodies the
contextual type narrows and the leading-dot form works there).
swift run swift-linter <package> detects the consumer's Lint/
nested package, builds it, and dispatches the lint run to the
consumer's Lint executable, which links engine + rule packs and
runs Lint.Run.run(paths:configuration:) against the consumer's
source tree.
Wire format note:
Lint.Manifestexists as a separate type for the cross-process JSON wire format used by the single-fileLint.swiftsubprocess path. Nested-package consumers do not cross a JSON boundary — metatypes flow directly throughLint.Configuration— so the consumer surface is the typed result-builder above, notLint.Manifest.
Lint/ is the canonical internal implementation; Lint.swift is
built on top of that. The engine's mental model is the typed
Lint.Configuration produced by a result-builder DSL — exactly what
Lint/Sources/Lint/main.swift constructs explicitly. Lint.swift is a
single-file front-end whose source is parsed by
Lint.File.Single.Extractor, lifted to a Lint.Configuration via
Lint.Configuration.lift, and then executed by the same
Lint.Run.run(paths:configuration:) entry the nested-package shape
calls directly. From the engine's perspective, both shapes converge at
the same internal type; the consumer's choice is purely ergonomic.
The asymmetry — recommend single-file, canonicalize on nested-package — is deliberate:
- The single-file shape minimizes consumer-side setup cost (one file, one parse, no nested SwiftPM resolution) for the common case of activating an institute bundle with optional brand-owner exclusions.
- The nested-package shape exposes the full power of the typed DSL (custom rule types, third-party rule packs, programmatic per-rule configuration) for cases the single-file cannot express.
- Internal canonicalization on nested-package keeps the engine's
contract single-source-of-truth — every front-end produces the same
downstream
Lint.Configuration. A future declarative-only front-end (e.g., a YAML mode) would ship as a new parser producing the same internal type, not as a parallel execution path.
Layer your configuration on top of a canonical configuration hosted at
a URL. Place the directive in the first 30 lines of Lint.swift (or
Lint/Sources/Lint/main.swift):
// swift-linter-tools-version: 0.1
// parent: https://raw.githubusercontent.com/<your-org>/.github/main/Lint.swift
import Linter
import Linter_Institute_Rules
Lint.run(dependencies: [
.package(
url: "https://github.com/swift-foundations/swift-institute-linter-rules.git",
branch: "main",
products: ["Linter Institute Rules"]
),
]) {
Lint.Rule.Bundle.institute
}Schemes accepted: http://, https://, file://. The driver fetches
each parent via curl (memoized per process), with cycle detection and
a depth-16 backstop. On any fetch failure the driver warns and falls
back to the consumer-only configuration — the chain is best-effort.
Per-rule overrides at any layer override deeper layers under "later
layer wins" semantics.
The conventional public pointer for an org's canonical configuration is
its .github repo's raw URL: <your-org>/.github/main/Lint.swift.
This mirrors SwiftLint's parent_config: cascade at the file layer.
swift-linter is pre-1.0. Minor-version boundaries (0.1.x → 0.2.0)
admit source-breaking changes; consumers pinning from: "0.1.0" should
plan to audit migration notes at each minor bump. The 1.0 inflection
will mark the API surface as stable under the standard SemVer contract
(no source-breaking changes in minor versions; deprecations precede
removals).
The known 0.1.x → 0.2 candidates are documented at the API surface
itself; the most prominent is the bare-form rename of Lint.Manifest
struct fields (see "Wire-key stability" below).
Lint.Manifest carries three array-shaped fields —
enabledRuleIDs, disabledRuleIDs, excludedPaths — that the engine
serializes to JSON when crossing the consumer-driver-shim subprocess
boundary. The JSON wire-keys for these fields ("enabledRuleIDs",
"disabledRuleIDs", "excludedPaths") are stable across 0.x; the
Swift property names may rename to bare forms (enabled, disabled,
excluded) in 0.2 to remove the namespace-implicit prefix
redundancy, with a serializer-side mapping preserving wire compat. JSON
consumers built against 0.1.x continue to work after a 0.2 Swift-side
rename; Swift API consumers face a one-line migration per field.
The implementation factors across five sibling packages:
| Package | Layer | Role |
|---|---|---|
| swift-linter (this) | L3 (Foundations) | Engine, CLI, reporters |
| swift-linter-rules | L3 | Default rule packs |
| swift-manifests | L3 | Manifest loader + parent-chain resolver |
| swift-manifest-primitives | L1 (Primitives) | Manifest.Dependency, Manifest.NestedPackage types |
| swift-linter-primitives | L1 | Lint.Configuration, Lint.Rule.Protocol, Lint.Filter, the typed-DSL surface |
The factorization reflects the institute's five-layer architecture: L1
primitives provide atoms (typed DSL surface, dependency-shape types);
L3 foundations compose them into running tools. A single-package
collapse would conflate the L1 typed-DSL surface (consumer-facing
type vocabulary) with the L3 engine (running orchestration), breaking
the layering. Consumers depend on swift-linter via the URL-form
.package(url:from:) declaration; the cohort's primitives are pulled
transitively.
A DocC catalog covering the rule catalog, configuration schema, and CI integration recipes is deferred to a separate cycle.
This package is public alpha (pre-1.0): interfaces are stabilizing and APIs may change until a first tagged release.
Maintained by Coen ten Thije Boonkkamp — available for Swift infrastructure and document-systems consulting: coen@coenttb.com.
Apache 2.0. See LICENSE.md.