Every Android team has a comment its reviewers type from memory. In a chat app, a classic one is “please use our Logger, not android.util.Log” — the team logger scrubs phone numbers and IDs before anything reaches a log file; Log.d does not. The comment lands on a PR, gets fixed, and lands again two weeks later on a PR from someone who joined after the last time. Nobody was careless. The rule simply lives in the one place a new developer can’t read: a reviewer’s head.
Signal-Android has exactly this rule, and they stopped typing it. Their repo has a lintchecks module with a detector called SignalLogDetector: any call to android.util.Log is reported as LogNotSignal — “Using ‘android.util.Log’ instead of a Signal Logger” — with a quick fix, and their root lint.xml promotes it to error. The comment became a red squiggle in the editor and a failed CI job.
That is the easy half. The hard half is the question this post is about: when a team rule deserves to be enforced by a machine, which machine? Android Lint, Detekt, ktlint, Konsist, the Kotlin compiler, the Gradle module graph — every one of them could plausibly enforce “use our logger”. They are not interchangeable, and “which linter is best?” is the wrong question.
Don’t pick a linter; pick what the rule needs. Does it need resolved types? An IDE quick fix? To see resources or the manifest? To ship inside a library to every consumer? To run on Kotlin Multiplatform code? Each answer narrows the tool. And before any linter: if the compiler or the module graph can refuse the code outright, let it.
This is part 2 of the Quality Gates series. Part 1 turned architecture rules for an E2EE module into CI gates. This one steps back to the everyday layer every Android app has — lint — and is written for the whole team: if you’ve never touched a lint.xml, the setup section is for you; if you run a platform team, the tool-selection table and the “being gated” box are the parts to argue with. Part 3 writes a custom rule and rolls it out across teams.
Table of contents
Open Table of contents
A Rule Has More Than One Place to Live
The instinct, once a comment repeats, is “write a lint rule”. Sometimes that’s right. But a rule can be enforced at several layers, and they differ on more than “strength”. Four questions separate them: how can someone get around it, how soon does the developer hear about it, what does a false positive cost, and who maintains it.
| Mechanism | How it’s bypassed | When you hear | False-positive cost | Upkeep |
|---|---|---|---|---|
Type / visibility (internal, sealed types, value classes) | not practically | while typing (compiler) | ~none | low |
Kotlin opt-in marker (@RequiresOptIn) | an explicit, greppable @OptIn | while typing | ~none | low |
@Deprecated(level = ERROR, replaceWith = …) on an API you own | un-deprecating it | while typing, with a built-in IDE fix | ~none | low |
| Module fence (feature module has no dependency on storage) | one line in a build.gradle.kts | at compile | ~none | low, but the build file needs its own guard |
| Lint rule (suppressible) | @SuppressLint, baseline, lint.xml | in the IDE if the rule is wired in, else in CI | depends on the rule | medium: lint API changes with every AGP |
| Lint rule (non-suppressible) | editing or deleting the rule | IDE + CI | must be near zero | medium |
| Code review / docs | anything | whenever a human notices | n/a | high, and invisible |
Two things in that table surprise people. First, the module fence — the gate part 1 argued you should reach for first — is one implementation(project(":storage")) away from being gone. It’s the cheapest strong gate, not an unbreakable one. Second, lint is not a single strength level: Android Lint lets a rule author set an issue’s suppressNames to an empty collection, which makes it impossible to suppress with any annotation, comment, lint.xml entry or baseline. For a security rule, that is harder to get around than a module fence.
The practical order still holds: if the compiler can refuse it, don’t lint for it. If your team keeps writing analytics.track("checkout_success") with a raw string, the fix is an API that only accepts a sealed AnalyticsEvent, not a rule that flags string literals. If you own LegacyImageLoader, mark it @Deprecated(level = DeprecationLevel.ERROR, replaceWith = ReplaceWith("ImagePipeline.load(url)")) and the IDE offers the migration for free — see the Kotlin @Deprecated docs. If an internal database API should only be called by the storage module, annotate it with an opt-in requirement and every other caller has to write a visible @OptIn.
Lint earns its place for what the compiler can’t express: call-site rules inside a module (a Composable and its ViewModel live in the same feature module, so no module fence separates them), rules about resources, manifests and Gradle files, and rules about third-party APIs you don’t own — like android.util.Log.
Five Questions That Pick the Tool
Once a rule has earned a linter, the choice between Android Lint, Detekt, ktlint and Konsist comes down to what the rule needs. The common shortcut — “formatting → ktlint, code smells → Detekt, Android stuff → Lint” — is roughly right and wrong at the edges: Android Lint runs happily on plain Kotlin/JVM modules, Slack’s lint rules include a long-method complexity check, and Detekt can resolve types. These questions are the real tie-breakers.
1. Is it formatting? Indentation, trailing commas, import order, wrapping. Then it’s not a lint rule at all — it’s a formatter’s job, and the formatter should fix it, not report it. Pick one of ktlint (rule-based, configurable through .editorconfig) or ktfmt (Google-style, almost no knobs; Block moved to it), and run it before code leaves the laptop. Signal’s lefthook.yml runs ./gradlew format on pre-push and refuses the push if that changed anything. Formatting arguments should never reach a human reviewer, and formatting errors should never be the reason CI is red.
2. Does it need resolved types, an IDE quick fix, or Android files? “Calls to android.util.Log” is a type question: a regex on Log.d( can’t tell android.util.Log from your own Log object or an import alias. Android Lint always works on resolved code (UAST backed by the Kotlin Analysis API), shows the result live in Android Studio, can attach a quick fix, and is the only one of these tools that sees AndroidManifest.xml, resources and Gradle files. If the rule needs any of those, it’s Android Lint.
3. Must the rule ship with a library? If your design-system team owns NiaButton and wants every consumer warned off raw Material Button, the rule should travel with the library. Only Android Lint does this: lintPublish packages the checks into the library’s AAR, and they run in every module that depends on it. Now in Android does exactly this — its DesignSystemDetector ships from core/designsystem via lintPublish(projects.lint) — and so does AndroidX: the Compose runtime publishes its own lint module. In a multi-team app, this is how the team that owns an API also owns the rules for using it.
4. Is it Kotlin-only, or does it run on KMP code? Detekt is a Kotlin static analyzer with a large built-in rule set (complexity, naming, coroutines, potential bugs) and runs on common, JVM, JS and native source sets, where Android Lint doesn’t go. Its trade-off is types: most rules are syntax-only and fast; rules that need types (such as ForbiddenMethodCall) only run in the type-resolution tasks, which compile each source set and are much slower. One user reported a type-resolved Detekt 2.0 run taking ~24 minutes, against about a minute before, on an ~80k-line KMP repo — a single report, driven by every Android variant being compiled, but it shows where the cost lives.
5. Is it a statement about the whole codebase’s structure? “Every class implementing Presenter must not take another presenter in its constructor” is a sweep across declarations, not a check at one call site. Konsist writes that as a JUnit test; part 1 covered it for import boundaries. It has no IDE feedback and no quick fix, but a Konsist rule is readable by anyone who can read a test. One caveat for teams adopting it now: its last release, v0.17.3, is from December 2024 — the repo is still active, but nothing has been released since; Element X, for one, builds against a community fork (com.github.jmartinesp:konsist:0.18.0).
And if the rule can only be judged by running the code — “this screen never blocks the main thread”, “decrypt fails closed” — it’s a test, not a linter.
Put together:
| The rule needs… | Reach for | Example |
|---|---|---|
| to be impossible, and you own the API | types, @RequiresOptIn, @Deprecated(ReplaceWith), module fence | raw-string analytics events → sealed event type |
| formatting | ktlint or ktfmt, auto-applied before push | trailing commas, wrapping |
| resolved types, a quick fix, resources/manifest/Gradle | Android Lint | android.util.Log → team Logger; PendingIntent without a mutability flag |
| to travel with a library to its consumers | Android Lint via lintPublish | design-system component rules |
| KMP source sets, or Kotlin-smell built-ins | Detekt | complexity thresholds, runCatching bans |
| a sweep over declarations, readable as a test | Konsist | ”presenters don’t depend on presenters” |
| runtime behavior | tests | fails-closed decryption |
What Real Apps Actually Run
Two open-source chat apps make the point better than any taxonomy: they solve the same problems with opposite stacks.
| Project | Stack (verified 2026-09-22) | Notable |
|---|---|---|
| Signal-Android | Android Lint + 9 custom detectors in lintchecks/ (8 with tests), root lint.xml, formatter on pre-push; AGP 9.4.0 | No Detekt. Custom rules are domain-specific: ThreadIdDatabaseDetector and RecipientIdDatabaseDetector catch mixing up two kinds of database ID. PRs run a faster parse-only linter; full lint runs on main (android.yml) — part 3 unpacks why. |
| Element X Android | Android Lint with one shared tools/lint/lint.xml, Detekt 1.23.8 + a custom rule set, compose-rules for Detekt (pinned to 0.4.28), ktlint 1.8.0, 16 Konsist test files; AGP 9.3.2, Kotlin 2.4.10 (versions) | Architecture rules live in Konsist, not lint. Its AGENTS.md tells coding agents to run ./gradlew lint and ./gradlew ktlintFormat — the gates double as the agent’s instructions. |
| Now in Android (Google sample) | Android Lint applied to every module by a convention plugin — including non-Android modules via com.android.lint — with checkDependencies = true and SARIF output | Custom DesignSystemDetector shipped with the design system via lintPublish. |
| Slack | slack-lints (0.11.1) and compose-lints (1.6.0), both open source | ”Common lint checks that run on our codebase on every PR” (Slack Engineering). During modernization of the iOS codebase, stricter SwiftLint rules applied only to directories holding “modern” modules, and disabled errors were tracked for later cleanup (Slack Engineering). |
| AndroidX | library-specific lint modules shipped inside the libraries | Compose, Fragment and Lifecycle warn you about their own misuse because the rules come in the AAR. |
| Faire | Detekt + published custom rules (v0.5.8, Apache-2.0) | An opinionated rule set worth reading before you write your own Detekt rule. |
What the table says: nobody runs a canonical stack. Signal bet on Android Lint because its rules are about Android APIs and types; Element X put architecture in Konsist and code style in Detekt + ktlint. What every serious setup shares is narrower: one shared config, applied to every module by a build-logic plugin, and a formatter nobody argues with.
Adopt Before You Write
Most rules you’re about to write already exist. Check four places first.
Built-in Android Lint checks you should promote. Lint ships hundreds of checks, most at warning, and warnings don’t break builds. The high-value move on day one is promoting a handful to error. Signal’s lint.xml is a good menu: StopShip (a // STOPSHIP comment fails the build), StringFormatMatches (a translation whose format arguments don’t match crashes at runtime), HardcodedText, and UnspecifiedImmutableFlag for PendingIntents. Consider SecretInSource too, which flags secrets such as API keys committed in source. The full catalog is at googlesamples.github.io/android-custom-lint-rules/checks.
Detekt’s configurable rules. ForbiddenImport and ForbiddenMethodCall cover a lot of “don’t use X” rules with a few lines of YAML — its default config even ships an example banning kotlin.io.print with the reason “Use a logger instead.” GlobalCoroutineUsage exists but is off by default. The catch: ForbiddenMethodCall needs type resolution, so it silently does nothing in the plain detekt task. It’s a legitimate way to get “use our logger” without writing code — without a quick fix, and without IDE feedback.
Compose rule sets. Compose mistakes (forwarding ViewModels down the tree, unstable parameters, missing modifier parameter) are common enough to have two maintained rule sets from the same Twitter origin: Slack’s compose-lints for Android Lint, and compose-rules for ktlint/Detekt. If you were about to write “a Composable must not take a repository”, look at ComposeViewModelForwarding and ComposeViewModelInjection first. Pick the one that matches the engine you already run, not both — they overlap by design.
Policy and security. Google’s Play Policy Insights ships as lint checks for Play policy issues, and android-security-lints bundles security checks with click-to-fix — useful, though the repo’s last commit was November 2025, so check it against your AGP before depending on it.
Only after those four does writing your own rule make sense — and part 3 argues that writing it is the cheap part.
Day-One Setup
If your project runs lint only when someone remembers, here is a minimal setup that holds up in a multi-module app. It’s modeled on Element X’s and Now in Android’s convention plugins; adapt names to your build.
Put one shared config where every module can see it, and apply it from build logic rather than copying it into each module:
// build-logic: applied to every Android application/library module
fun Lint.teamDefaults(rootDir: File) {
lintConfig = File(rootDir, "config/lint/lint.xml") // one severity file for all modules
abortOnError = true // errors fail the build
warningsAsErrors = false // promote deliberately, in lint.xml, not wholesale
checkDependencies = false // per-module tasks stay small and cacheable
ignoreTestSources = true
checkGeneratedSources = false
sarifReport = true // machine-readable output for CI annotations
}
checkDependencies is a real trade-off, not a default to copy blindly. It’s off by default; turning it on in the app module (Now in Android does) lets lint analyze libraries together and catch cross-module issues such as unused resources, at the cost of one big task instead of many small cached ones. Element X keeps it off. Start off; switch it on if you rely on whole-app checks.
Then the severities, in the shared lint.xml:
<lint>
<issue id="StopShip" severity="fatal" />
<issue id="StringFormatMatches" severity="error" />
<issue id="UnspecifiedImmutableFlag" severity="error" />
<issue id="HardcodedText" severity="error" />
<!-- Every ignore carries a reason, or it doesn't get merged. -->
<issue id="MissingTranslation" severity="ignore" />
</lint>
Then the baseline — the step that makes lint adoptable in an existing codebase. A baseline records every current violation so they stop failing the build, while anything new still does:
android {
lint {
baseline = file("lint-baseline.xml")
}
}
# First run writes the file; the flag stops that run from failing.
./gradlew :app:lintDebug -Dlint.baselines.continue=true
# Later, to regenerate deliberately:
./gradlew :app:updateLintBaseline
Three things beginners trip on, all from the official lint guide and its baseline notes:
- Baselines are ignored in the editor on purpose. Android Studio still shows legacy warnings in the file you’re editing while CI is green. That’s the design: fix what you touch.
./gradlew lintchecks only one variant. Locally, run the module you changed:./gradlew :feature:chat:lintDebug. It’s much faster than linting the whole app, and close to what CI runs on your module (CI may lint a specific flavor, such as Signal’slintPlayProdDebug).- One baseline per module. In a 60-module app that’s many files. Treat them as debt ledgers that should only shrink — part 3 shows how to make CI enforce that.
Finally, suppression. Sometimes a rule is wrong for one line, and suppressing it is correct. Do it narrowly and say why:
@SuppressLint("HardcodedText") // Debug-only screen, never shipped in release builds
Suppress one issue ID on the smallest scope — the line or function, never the file — with a comment a reviewer can argue with. tools:ignore does the same in XML, and @Suppress("RuleId") for Detekt.
If you’re the one being gated. A lint gate is a contract, and it binds the people who wrote it too. The baseline means legacy violations aren’t yours — if touching an old file fails your PR on code you didn’t write, that’s a rollout bug; report it rather than suppressing it. A suppression is a signed statement: legal, sometimes right, and expect it to be counted. A false positive is a bug in the rule, and the rule’s owner should fix it with a test case. A good message tells you what’s wrong, why the team cares, and what to write instead — “Using android.util.Log instead of a Signal Logger”, with a one-click fix, is the bar. And if a rule costs you more than a coffee’s worth of CI time per PR, that’s a configuration problem the platform team owes you, not a tax you owe them. Part 3 is written for the people on the other side of this box.
Versions as of September 2026
Tooling moves fast enough that version numbers in a lint article rot within a year. As of this writing:
- AGP 9.4 (9.4.0 on 1 Sept 2026, 9.4.1 since 18 Sept) is current; lint’s version is AGP’s plus 23 (lint 32.x for AGP 9.x). Since AGP 9.3, lint always uses the K2 Kotlin frontend for Kotlin 2 projects — the
android.lint.useK2Uastflag is gone. Older blog posts telling you to opt in are out of date. - Detekt: 1.23.8 is the stable line; 2.0 is still in alpha (alpha.6, August 2026). Don’t adopt alphas as a team gate.
- ktlint: 1.8.0 stable; 2.0 in alpha. Pin the version — formatter upgrades reformat code (1.8.0 alone promoted five experimental rules to standard).
- compose-lints 1.6.0; compose-rules 0.6.6 (note Element X pins the Detekt flavor to 0.4.x because of a detection bug in later versions — their comment links the compatibility matrix).
- Konsist 0.17.3 (December 2024, no release since).
What This Setup Doesn’t Give You
A shared config, a baseline and a formatter get you surprisingly far: most “please don’t” comments are already a built-in check at the wrong severity. What they don’t give you is your rules — the Signal-style “use our logger”, the domain-specific “don’t pass a thread ID where a recipient ID goes”. Those need a custom rule, and a custom rule in a multi-team app is less a piece of code than a small product: it needs an owner, tests that survive lint’s own code rewriting, a message that behaves like an API, a rollout plan, a speed budget and a way to be switched off.
That’s part 3: one rule, built end to end, and what happens when ten teams have to live with it — plus what the iOS side of the same app can and can’t copy.
Sources
Tools and docs
- Improve your code with lint checks — Android Developers
- Lint DSL reference (AGP) ·
lintChecks/lintPublish - Android Lint API guide · baselines · checks catalog
- AGP source: K2 UAST always on in 9.3
- Detekt · type resolution · issue #8882
- ktlint · ktfmt · Konsist
- Kotlin opt-in requirements ·
@Deprecated
Rule sets
Real projects
- Signal-Android:
lintchecks/·lint.xml·lefthook.yml·android.yml - Element X Android:
tests/konsist·tests/detekt-rules·AGENTS.md - Now in Android: lint convention plugin ·
lint/ - Slack Engineering: Developing in the open · Scaling Slack’s mobile codebases: modernization
- Block Engineering: Adopting ktfmt and Detekt