Skip to content

Reduce lint hot-path overhead#6826

Draft
Brett-Best wants to merge 45 commits into
realm:mainfrom
Brett-Best:codex/lint-hot-path-performance
Draft

Reduce lint hot-path overhead#6826
Brett-Best wants to merge 45 commits into
realm:mainfrom
Brett-Best:codex/lint-hot-path-performance

Conversation

@Brett-Best

@Brett-Best Brett-Best commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR reduces lint and correction hot-path overhead across rule execution, syntax classification, file caching, and parallel scheduling. In SwiftLint's OSS-check suite, the combined series improves end-to-end lint time by 9–35%.

  • move the remaining lint-mode rules and SwiftSyntax-mode custom rules off SourceKit by deriving their structure and syntax-kind information from SwiftSyntax
  • skip syntax-tree rewriting for correctable rules when a file has no violations
  • replace repeated full-tree and classification work with targeted scans, cheap pre-checks, and per-file caches
  • remove repeated per-token, per-node, per-rule, and per-file invariant work across frequently executed rules
  • reduce cache synchronization and violation post-processing overhead
  • use file-level parallelism more effectively by running rules serially per file when appropriate and dispatching the largest files first
  • fix sorted-index and SourceKit-compatible comment-range behavior

Motivation

Profiling a large repository showed that a substantial portion of runtime was spent outside rule-specific analysis: SourceKit editorOpen requests, repeated syntax-tree walks, classification of unrelated tokens, full-tree correction rewrites on clean files, filesystem and cache synchronization, repeated source-line and term-list construction, task-local bookkeeping, violation-region partitioning, and concurrent walks contending on the same syntax arena.

Most of this work is invariant for a rule type or file, can be cached, or can be avoided entirely after a cheap candidate check. This series moves invariant work out of repeated paths and makes expensive analysis conditional on a file having relevant content.

Details

Remove unnecessary SourceKit work

  • port indentation_width, literal_expression_end_indentation, multiline_function_chains, multiline_parameters_brackets, statement_position, and the vertical-whitespace brace rules to SwiftSyntax-backed implementations
  • add a shared SwiftSyntax-derived syntax map for rules that still need syntax-kind filtering
  • execute custom rules configured for SwiftSyntax mode without consulting SourceKit, including inherited default_execution_mode
  • preserve SourceKit-compatible line-comment ranges for LF, CRLF, and CR line endings without extending block comments

Avoid unnecessary correction and rule work

  • validate correctable rules first and skip rewriter construction and serialization when a file has no violations
  • skip adjusted body-line analysis when the physical body span is already within the configured threshold
  • gate comment_spacing regex work behind a cheap UTF-16 scan and match directly against the file's string view
  • hoist per-file invariants and add cheap preconditions in colon, comma, closure_end_indentation, and redundant_self
  • skip violation-region partitioning and identifier-set construction when no violations exist or the file has one enable-everywhere region
  • reuse the rule identifier already resolved by lint instead of copying its RuleDescription again in performLint

Cache and narrow repeated analysis

  • replace the per-file artifact cache's concurrent queue with an NSLock while retaining computation deduplication
  • cache comment byte ranges per file and compute them with one position-tracking visitor pass
  • speed up CommentLinesVisitor by avoiding empty-trivia re-lexing, reversed trivia copies, repeated token-kind checks, and per-token converter lookups
  • collect indentation_width syntax data in one tree walk
  • cache source lines once per file for vertical_parameter_alignment
  • precompute inclusive_language term lists once per file
  • cache allowedSymbolsAndAlphanumerics, minimum-Swift-version support, environment state, file emptiness, and targeted file metadata
  • avoid general syntax classification and full-tree traversal where a token, root-node, line, or regex-candidate scan is sufficient
  • memoize nested function-chain suffixes to avoid quadratic re-walking

Improve scheduling

  • skip collection dispatch when no collecting rule is enabled
  • when the number of files is at least the active processor count, run rules serially per file and rely on file-level parallelism; few-file invocations retain per-file rule parallelism
  • sort parallel lint work by descending file size so expensive files start early instead of leaving one core processing the largest file at the end

Correctness fixes

  • make RandomAccessCollection.firstIndexAssumingSorted(where:) return nil, rather than endIndex, when no element matches
  • honor custom-rule execution_mode and inherited default_execution_mode
  • extend bridged line-comment ranges correctly across LF, CRLF, and CR endings while keeping block-comment extents unchanged
  • invalidate cached comment byte ranges with the rest of a file's cached artifacts

Impact

Large multi-file lint runs should spend less CPU time in SourceKit, syntax-tree bookkeeping, filesystem access, cache synchronization, rule post-processing, and shared-arena contention. Clean --fix runs avoid reconstructing the syntax tree for every correctable rule. Single-file and editor integrations retain concurrent rule execution. No configuration migration is required, and SourceKit execution remains available where explicitly selected or required.

Validation

  • the earlier 13-commit tree passed 101 focused Swift tests covering the SourceKit-free ports, custom-rule modes, comment/line handling, file caching, collecting behavior, and affected built-in rules
  • the 15-commit follow-up applied cleanly and was pushed without waiting for another test run; CI provides validation for the final tree
  • metadata rewriting preserved the applied tree, and all 28 commits use Brett-Best <brettbest@live.com.au> as author and committer

git diff --check reports only the two intentional Markdown hard breaks in this PR's changelog entries.

@SwiftLintBot

SwiftLintBot commented Jul 16, 2026

Copy link
Copy Markdown
1 Warning
⚠️ Big PR
20 Messages
📖 Building this branch resulted in a binary size of 28495.37 KiB vs 28357.8 KiB when built on main (0% larger).
📖 Linting Aerial with this PR took 0.41 s vs 0.65 s on main (36% faster).
📖 Linting Alamofire with this PR took 0.57 s vs 0.95 s on main (40% faster).
📖 Linting Brave with this PR took 3.16 s vs 6.01 s on main (47% faster).
📖 Linting Brigade with this PR took 1.38 s vs 17.25 s on main (92% faster).
📖 Linting DuckDuckGo with this PR took 14.41 s vs 26.6 s on main (45% faster).
📖 Linting Firefox with this PR took 5.8 s vs 10.67 s on main (45% faster).
📖 Linting Kickstarter with this PR took 3.57 s vs 7.17 s on main (50% faster).
📖 Linting Moya with this PR took 0.28 s vs 0.37 s on main (24% faster).
📖 Linting NetNewsWire with this PR took 1.31 s vs 2.37 s on main (44% faster).
📖 Linting Nimble with this PR took 0.31 s vs 0.53 s on main (41% faster).
📖 Linting PocketCasts with this PR took 3.36 s vs 6.99 s on main (51% faster).
📖 Linting Quick with this PR took 0.22 s vs 0.35 s on main (37% faster).
📖 Linting Realm with this PR took 1.56 s vs 2.46 s on main (36% faster).
📖 Linting Sourcery with this PR took 0.89 s vs 1.49 s on main (40% faster).
📖 Linting Swift with this PR took 2.29 s vs 4.31 s on main (46% faster).
📖 Linting SwiftLintPerformanceTests with this PR took 0.17 s vs 0.17 s on main (0% slower).
📖 Linting VLC with this PR took 0.72 s vs 1.18 s on main (38% faster).
📖 Linting Wire with this PR took 7.98 s vs 15.43 s on main (48% faster).
📖 Linting WordPress with this PR took 5.11 s vs 9.81 s on main (47% faster).

Generated by 🚫 Danger

@Brett-Best
Brett-Best force-pushed the codex/lint-hot-path-performance branch from 4459be4 to e7c9b0b Compare July 16, 2026 20:46
@Brett-Best

Copy link
Copy Markdown
Contributor Author

Some other benchmarks which also include the fix jpsim/SourceKitten#846

Linting Aerial with this PR took 0.29 s vs 0.43 s on `main` (32% faster).
Linting Alamofire with this PR took 0.43 s vs 0.58 s on `main` (25% faster).
Linting Brave with this PR took 2.51 s vs 3.76 s on `main` (33% faster).
Linting DuckDuckGo with this PR took 10.73 s vs 13.83 s on `main` (22% faster).
Linting <<work project>> with this PR took 3.97 s vs 7.52 s on `main` (47% faster).
Linting Firefox with this PR took 3.99 s vs 5.59 s on `main` (28% faster).
Linting Kickstarter with this PR took 2.77 s vs 4.47 s on `main` (38% faster).
Linting Moya with this PR took 0.19 s vs 0.28 s on `main` (32% faster).
Linting NetNewsWire with this PR took 0.98 s vs 1.3 s on `main` (24% faster).
Linting Nimble with this PR took 0.24 s vs 0.32 s on `main` (25% faster).
Linting PocketCasts with this PR took 2.43 s vs 6.02 s on `main` (59% faster).
Linting Quick with this PR took 0.17 s vs 0.23 s on `main` (26% faster).
Linting Realm with this PR took 1.31 s vs 2.58 s on `main` (49% faster).
Linting Sourcery with this PR took 0.63 s vs 1.16 s on `main` (45% faster).
Linting Swift with this PR took 1.64 s vs 2.39 s on `main` (31% faster).
Linting SwiftLintPerformanceTests with this PR took 0.13 s vs 0.14 s on `main` (7% faster).
Linting VLC with this PR took 0.51 s vs 0.69 s on `main` (26% faster).
Linting Wire with this PR took 5.6 s vs 8.17 s on `main` (31% faster).
Linting WordPress with this PR took 3.72 s vs 5.16 s on `main` (27% faster).

Brett-Best and others added 27 commits July 25, 2026 09:50
Comparing via debugDescription(includeTrivia:false) builds a full
indented debug dump of both operand subtrees (keypath-driven child
names included) for every infix operator expression. Comparing the
lazy token streams short-circuits on the first mismatch and allocates
nothing. ~6% of a large lint run in profiling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CharacterSet union was rebuilt through CoreFoundation on every
checked identifier; it only changes when allowedSymbols changes.
~83% of identifier_name's cost in profiling a large repo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
comment_spacing and period_spacing only need comment byte ranges, but
obtained them via file.syntaxClassifications, which runs SwiftSyntax's
general-purpose classifier over every token in the file. A direct
trivia walk over the cached syntax tree yields the same ranges without
classifying non-comment tokens. Both rules are migrated together since
they shared the classification cache; migrating one alone would move
the cost to the other.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Skip Linter.collect's concurrentPerform when no CollectingRule is
  enabled: it dispatched rules.count no-op iterations per file, whose
  existential/RuleDescription copies and task-local bookkeeping cost
  ~14% of a large lint run.
- Memoize the minSwiftVersion support check per rule type; it compared
  freshly parsed version strings and copied the full RuleDescription
  per rule and file.
- Read SWIFTLINT_DISABLE_SOURCEKIT once; ProcessInfo.environment
  rebridges the entire environment on every access.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The documentation promises nil when no element satisfies the predicate,
but the termination check tested the midpoint index instead of the
partition boundary, returning count in that case. Existing callers only
survived because they immediately formed (empty) suffix slices from the
result; subscripting callers crash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
statement_position, vertical_whitespace_closing_braces,
vertical_whitespace_opening_braces, and indentation_width resolve
syntax kinds through a shared SwiftSyntax-derived syntax map;
multiline_function_chains, multiline_parameters_brackets, and
literal_expression_end_indentation are ported to SwiftSyntax visitors
reproducing SourceKit's structure geometry. The shared map extends
comment tokens over their terminating newline to match sourcekitd's
extents, and rules build it only for files whose regexes match at all.

With no enabled rule requiring SourceKit, lint runs never pay the
per-file sourcekitd editorOpen round trip (~13% of CPU on a large
repo profiling run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
execution_mode/default_execution_mode existed in configuration and in
isEffectivelySourceKitFree, but validate() always used the
sourcekitd-backed syntax map regardless of mode — a swiftsyntax-mode
configuration would even hit the "Never call this for file that
sourcekitd fails" trap when SourceKit is unavailable. Dispatch on the
effective mode and resolve kinds from the SwiftSyntax-derived map,
built lazily only when a kind-filtered pattern matches.

Also propagate defaultExecutionMode through parent/child configuration
merging, which rebuilt CustomRulesConfiguration and silently dropped it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Cache validation read the full attribute dictionary (including an
  enumeration of all extended attributes) per file just for the
  modification date; ask for the single resource value instead.
- Read a file's emptiness once per file instead of once per rule and
  file: the check acquired the contents queue lock on every call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- mark: materialize each token's leading trivia once instead of once
  per piece, and gate the regex on a cheap substring pre-check.
- file_length: compute everything from the root node without walking
  the entire tree.
- trailing_whitespace: scan lines for candidates first and only
  pre-compute comment/string-literal line information when any exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sourcekitd includes the terminating newline in line comment tokens but
ends block comment tokens at the closing delimiter. Extending all
comment kinds suppressed violations following block comments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three rules walk the file for comment trivia; the result is now shared
through the file cache. Byte positions are tracked incrementally during
a single visitor pass: SyntaxProtocol.position and token-sequence
iteration climb the tree per call, which degrades sharply on deeply
nested expression trees.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
visitPost fires for every call in a chain and each computation shares
its suffix with the enclosing call's, so long chains were re-walked
quadratically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every rule walks the same file's syntax tree, and concurrent walks
contend on the shared syntax arena's reference counts, slowing all of
them down. With at least as many files as cores, file-level parallelism
already saturates the machine, so rules now run serially within each
file in that case. Few-file invocations (single-file lints, editor
integrations) keep concurrent rule execution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rewriter-based corrections reconstructed the entire syntax tree and
serialized it back to a string for every correctable rule and file,
even when there was nothing to correct — the dominant cost of --fix
runs on clean codebases. Detect violations first via validate(file:),
which reports a superset of what the rewriter corrects (the rewriter
additionally skips disabled regions), and only rewrite when needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extract trailing_whitespace's candidate collection into a helper to
reduce cyclomatic complexity, wrap an overlong line, and move
indentation_width's helper visitors into their own file to respect
the file length limit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tax map

The SourceKit-free syntax map extended line comments over their
terminating newline only for LF line endings; the seven rules using it
now also run on platforms where checkouts commonly use CRLF.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With serial per-file rule execution, a huge file dispatched near the
end of a run occupies a single core while the remaining cores sit
idle. Sorting files by on-disk size before dispatch hides long
single-file runtimes behind the remaining work regardless of core
count. Reporting order for parallel runs is already
scheduler-dependent, so this changes no observable behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-file artifact cache guarded every slot access with a concurrent
DispatchQueue, taking a barrier sync on writes and a plain sync on reads.
Each lint forces roughly a dozen slots per file across all rules, so the
queue's dispatch overhead was a measurable share of a run. An NSLock with
the same computed-outside-the-lock / DispatchGroup-dedup semantics is
cheaper for this short-critical-section workload. Also invalidate the
comment-byte-ranges slot, which was missing from invalidateAll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The body-length rules computed the comment/whitespace-adjusted line count
for every body, forcing the per-file linesWithTokens set and allocating a
Set of the body's line range each time. The effective count can never
exceed the physical brace-to-brace span, so bodies whose span is already
within the threshold cannot violate and skip the analysis entirely. Where
the count is still needed, it is now derived without materializing a Set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gate trivia materialization on a byte-length check so empty-trivia tokens
skip re-lexing, iterate trivia forward instead of copying reversed arrays,
compute the token kind only when needed to detect end-of-file, and resolve
line numbers through a per-line bounds cache instead of a converter lookup
per token. Comment-line results are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rule ran separate tree walks for multi-line condition continuations
and multiline string-literal spans; merge them into one visitor pass.
Rename the helper file to match its renamed type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SourceLocationConverter.sourceLines rebuilds the whole file as a fresh
[String] on every access; the rule invoked it once per parameter of every
multi-parameter declaration. Cache it once per file and compute grapheme
columns only for the parameters actually compared.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
allTerms and allAllowedTerms were re-derived (set union, lowercasing,
dedup, sort) on every visited token. Compute them once per file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hoist the stringView access out of the per-comment loop and run the
violation regex only on comments a cheap UTF-16 unit scan cannot rule
out, matching against the file's string view in place instead of
re-materializing each comment body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hoist per-file invariants out of per-node work and gate expensive checks
on cheap pre-conditions in these four rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Skip region partitioning and identifier-set construction when they cannot
affect the result (no violations, or a single enable-everywhere region).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Brett-Best and others added 5 commits July 25, 2026 09:50
lint already resolves the identifier and passes it into the rule context;
performLint recomputed it. Reading it goes through the rule's description
witness, which copies the whole RuleDescription value, so this removes one
such copy per rule and file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rule asked for `syntaxClassifications`, which runs SwiftSyntax's
general-purpose classifier over every token in the file — resolving
keyword, identifier, type and literal classifications through key path
comparisons — and then discarded everything that was not a doc comment.
Doc comment classifications are a 1:1 mapping of the `docLineComment` and
`docBlockComment` trivia piece kinds, so a single trivia walk reaches the
same ranges, in the manner already used for `commentByteRanges()`.

Adjacent same-kind ranges are coalesced as the classifier does, so the
reported ranges are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
object_literal rendered `trimmedDescription` of the called expression for
every function call in the file, which rebuilds that subtree through key
path writes just to compare the result against four class names. Only
`X(...)` and `X.init(...)` can match, so the identifier naming `X` now
rules a call out first and the description is materialized only for the
few calls that can still violate. The per-node arrays of candidate names
are hoisted to static sets.

`containsOnlyAllowedCharacters` built a bitmap-backed `CharacterSet` from
every checked identifier to test it against the allowed set. It now scans
the name's scalars, settling ASCII letters and digits inline since the
allowed set always contains them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`sourceKitFreeSyntaxMap()` re-derived the whole SourceKitten token array
and syntax map on every call, even though it is a pure function of the
file and its bridge tokens are already cached. Several rules request it
independently per file — `statement_position` alone builds it four times,
plus both vertical whitespace rules and custom rules in SwiftSyntax mode —
so the array map and the comment-extent adjustment ran repeatedly for the
same result. Move it into the per-file artifact cache alongside the other
derived artifacts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each of the pipeline's five jobs cloned the full repository, which carries
~965 MB of history that is almost entirely historical blobs no job reads.
Filtering blobs out of the clone and letting git fetch them on demand cuts
a cold checkout from 104s/965 MB to 5s/15 MB; after running oss-check's
merge-base, diff and worktree operations the checkout still holds 16 MB.

All commits and trees are still fetched, so history-dependent steps keep
working: oss-check resolves `git merge-base origin/main` and checks the
result out with `git worktree add --detach`, and Danger computes the PR
diff. A `--depth` shallow clone would break both, which is why the filter
is used instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Brett-Best
Brett-Best force-pushed the codex/lint-hot-path-performance branch from f1b5834 to eec7ebf Compare July 25, 2026 00:52
Brett-Best and others added 13 commits July 25, 2026 11:07
`BUILDKITE_GIT_*` are protected variables: setting them in pipeline `env`
is ignored and logs "Ignored BUILDKITE_GIT_CLONE_FLAGS" on every build, so
the previous commit changed no behaviour and only added warning noise.

Keep the finding as a comment instead, since the flag still belongs in the
agent configuration, along with the measurements that justify it.
The eighteen repositories were cloned one after another, and each was
checked out in full even where linting is restricted to a subtree. Brave
alone took 67s to fetch 1.9 GB so that 6% of it could be linted; Swift
fetched its whole tree for `stdlib`.

Fetch them concurrently, since cloning is network-bound, and for the
repositories restricted with `included:` fetch no file contents up front
and materialize only that subtree. Tags are skipped everywhere as only the
tip commit is ever checked out. Over Brave, Swift, Alamofire and Moya this
goes from 79.2s and 2.2 GB to 9.3s and 182 MB, with the linted file sets
unchanged: 1314 Swift files under Brave's `ios/brave-ios` and 516 under
Swift's `stdlib`, as before.

The sparse path is derived from the `included:` config so the two cannot
drift apart and silently shrink the comparison. Clone failures now raise
instead of being ignored, which sequential runs got away with but which
would surface as confusing interleaved errors later on. `git -C` replaces
`Dir.chdir`, which mutates the whole process's working directory and would
race across the clone threads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collecting the rule's doc comment ranges in its own visitor added a second
full walk over every file's token trivia, when the per-file cache already
walks exactly that trivia for `commentByteRanges()`, which comment_spacing,
period_spacing and indentation_width force on nearly every run.

Collect the doc comment ranges during that existing pass and let the rule
read them from the cache, so the walk happens once. Linting DuckDuckGo with
all rules goes from 87.47s to 85.24s of CPU, taking the branch's total to
8.1% below its merge base, with violations unchanged.

Ranges are handed back as source positions rather than byte ranges so the
rule needs no SourceKitten import, and adjacent same-kind ranges are still
coalesced to match the classifier these ranges used to come from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rule read `SourceLocationConverter.sourceLines` once per element of
every dictionary literal, and that property materializes every line of the
file as a fresh `[String]` on each access, making the rule quadratic in
files holding large dictionary literals. This is the same defect already
fixed in vertical_parameter_alignment; collection_alignment was missed.

Compute the lines once per file. On realm-swift, whose test files are both
very large and dictionary-heavy, the rule drops from 0.336s to 0.022s, and
on DuckDuckGo from 0.561s to 0.185s. Violations are unchanged.

Found by profiling realm-swift, which gained the least from this branch of
any repository oss-check measures; its few-but-huge files expose costs that
scale with file size rather than file count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only the first key and keys starting a new line are ever compared, so the
grapheme column of every other key was computed and discarded. Resolve them
where they are used instead, which lets single-line literals skip reading
the source lines entirely.

This mirrors what vertical_parameter_alignment already does, so the two
rules that share this shape now share their approach. The measured gain is
small on its own — caching the source lines had already removed the
quadratic term, leaving collection_alignment at 0.185s on DuckDuckGo, which
this takes to 0.156s, and unchanged at 0.02s on realm-swift.

Sharing `validate` with the array path, which compares raw UTF-8 columns,
means the column basis has to be passed in, and memoizing the first key's
column across iterations turns the mapping into a loop. Violations are
unchanged on both corpora.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolving a command's character position read
`SourceLocationConverter.sourceLines`, which materializes every line of the
file as a fresh `[String]`, once per command comment in the file. Compute
the lines once per file instead.

This is the third and last occurrence of this pattern in the codebase,
after vertical_parameter_alignment and collection_alignment; a search for
`sourceLines` now finds no remaining uncached reads. The gain here is
smaller than in those rules because command comments are rare — the worst
file across DuckDuckGo, realm-swift and SwiftLint itself holds 65 of them,
and most hold none — but the quadratic term is gone either way.

Violations are unchanged across all three of those repositories, including
SwiftLint's own, which carries 393 command comments and therefore exercises
this path hardest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both vertical whitespace rules discard regex matches that intersect a
comment or string, and reached those kinds through `sourceKitFreeSyntaxMap`,
which runs SwiftSyntax's general-purpose classifier over every token in the
file. That classifier resolves keyword, type and identifier classifications
through key path comparisons, and these two rules discard all of it.

Comments and strings are both classified context-free — comments by trivia
piece kind, strings by token kind, since contextual classification only
applies to identifiers — so a single token walk reaches the same ranges.
Build a map holding just those tokens and hand it to the existing matching
code unchanged: it only tests disjointness against `commentAndStringKinds`,
so omitting the other kinds cannot change a result.

Linting DuckDuckGo with each rule alone drops from 40.2s to 10.3s of CPU
for the opening braces rule and 25.0s to 8.5s for the closing braces rule,
against a parse-only floor of 8.4s, leaving both close to free. A full
all-rules run drops 7.8%, from 87.6s to 80.7s of CPU.

Violations are identical across DuckDuckGo, realm-swift and SwiftLint
itself — 686,513 in total — and `--fix` output is byte-identical over the
37 files these rules correct in realm-swift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reporting was the single-threaded tail of a run: profiling DuckDuckGo with
every rule showed the last stretch on one thread inside JSONSerialization,
bridging a dictionary per violation to NSDictionary and re-sorting its keys
each time — `CFSortIndexes` and string comparisons for all 590,205 of them.

Encode an `Encodable` violation instead, which leaves escaping to
Foundation. `JSONEncoder` is given the same formatting options, including
`.sortedKeys`. That option is not redundant despite the coding keys being
declared in the order it sorts them into: without it `JSONEncoder` emits
keys in an unrelated order, so it is what holds the report's shape.

Serializing 590,205 violations takes 1765ms against 2601ms through
`JSONSerialization`, taking a DuckDuckGo run with `--reporter json`, which
is what oss-check uses, from 13.00s to 12.74s of wall clock. Writing the
JSON by hand reached 11.79s, but owning the escaping is not worth the
difference.

Output stays byte for byte identical, checked against whole-file reports
from the `JSONSerialization` build, and violations are unchanged across
DuckDuckGo, realm-swift and SwiftLint itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rendering the report was the only part of a run that did not parallelize:
profiling DuckDuckGo with every rule showed the last stretch on a single
thread, and every sample in it belonged to the reporter. Serializing
590,205 violations through `JSONSerialization` bridged a dictionary per
violation to `NSDictionary` and re-sorted its keys each time.

`JSONEncoder` alone barely helps, because it does the same two things —
boxing each value, then sorting each object's keys for `.sortedKeys`. The
array is a concatenation of independent objects, so split it into a chunk
per core, encode those concurrently, and join the fragments. Foundation
still owns escaping and formatting.

Linting DuckDuckGo with `--reporter json`, which is what oss-check runs,
goes from 13.24s to 10.74s of wall clock. Writing the JSON by hand and
keeping it serial reached 11.46s, so this is both faster and leaves the
escaping alone.

Chunking is skipped below a couple of thousand violations, where splitting
costs more than it saves, and for an empty report. Output is byte for byte
unchanged, checked against whole-file reports from the `JSONSerialization`
build, and violations are unchanged across DuckDuckGo, realm-swift and
SwiftLint itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`custom_rules` resolved syntax kinds through SourceKit unless a rule or the
configuration asked otherwise, which parses every file a second time on top
of the tree SwiftLint has already built. Over DuckDuckGo that was 13% of a
run's CPU, and linting it with every rule drops from 83.7s to 74.8s.

The rule already documented this default: its description says rules
"default to SwiftSyntax mode for improved performance", and
`ExecutionMode.default` is documented as "uses SwiftSyntax by default unless
overridden". Only the two places applying the fallback disagreed, so this
makes the behaviour match what is written.

The two tests asserting otherwise are updated. Their own comments described
the mode as implicit SwiftSyntax and the rule as SourceKit-free while
asserting the opposite, so they are brought in line rather than kept.

Violations are unchanged across DuckDuckGo, realm-swift and SwiftLint
itself. Projects needing SourceKit's kinds can still ask for them with
`execution_mode: sourcekit` or `default_execution_mode: sourcekit`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`URL.relativeDisplayPath` reaches `FileManager.currentDirectoryPath`, and so
a `getcwd` syscall, on every access. Reporters render a relative path per
violation — code_climate renders two, one of them inside the fingerprint —
so a run over a large code base made hundreds of thousands of them.

Add a variant taking the base directory and let each reporter resolve it
once. It stays out of `URL.cwd` itself, which cannot cache: SwiftLint
changes the process working directory when given `--working-directory`.

Linting DuckDuckGo with every rule:

    code_climate   60.7s -> 42.3s
    sarif          28.3s -> 19.1s
    sonar_qube     25.7s -> 17.6s

Reports are byte for byte unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The set is short of a repository shaped to stress the linter rather than to
be an app. Every entry is a real project of broadly the same shape — many
files of a few hundred lines each — and a rule whose cost scales with file
size is close to invisible across all of them. That is how a quadratic read
of a file's source lines survived in collection_alignment.

Brigade is built for that gap: a handful of very large catalogue files
against many small ones, non-ASCII identifiers, CRLF and CR terminators,
files dense in disable commands, and custom rules with kind filtering. It
lints in 1.32s here against 10.51s at this branch's merge base, where the
existing repositories manage about 1.5x, and adds roughly a minute to the
job.

Its config is kept: the custom rules and the exclusions in it are what the
corpus measures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Linting a file whose nesting runs deep enough terminates SwiftLint. The
crash report shows EXC_BAD_ACCESS at the stack guard region, 721 frames,
with parseIfExpression -> parseCodeBlock -> parseCodeBlockItemList ->
parseStatement repeating about a hundred times and `concurrentMap` at the
bottom: the parse runs on a worker thread and exhausts its stack.

`Parser.parse(source:)` takes no nesting bound, so swift-syntax's ceiling of
256 applies. That ceiling exists to prevent exactly this, but it is sized
for a main thread's stack; on a worker the stack is gone at around a
hundred levels, long before the ceiling can report the source as too deeply
nested. Pass a bound that fits the stack the parser actually runs on.

Only the buffer-taking entry point accepts the bound, so the source is
handed over as UTF-8 rather than as a string.

Violations are unchanged across DuckDuckGo, realm-swift and SwiftLint
itself, none of which nest anywhere near the bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants