Skip to content
HeyDai blog
Go back

Your Custom Lint Rule Works. Will Ten Teams Accept It?

The rule took an afternoon. A detector that flags android.util.Log and offers to replace it with the team Logger, a registry, two tests, all green. It went into the shared lint module on Friday.

By Wednesday it had met ten teams. The payments team’s PR failed on forty Log.d calls in a file they had touched to rename a parameter — none of them theirs. The media team found that the rule fired on their own Log wrapper imported under an alias, and added @SuppressLint("DirectAndroidLog") to the file to get a release out. Someone improved the rule’s message wording, and every module baseline stopped matching, so every open PR went red at once. CI on the app module was a few minutes slower, and three people asked in the platform channel whether lint “has always been this slow”. Nobody asked who owned the rule, because nobody knew.

That week is a composite. The mechanics behind it — baselines keyed on message text, suppressions as the path of least resistance, lint as the slowest CI step — are documented in lint’s own docs, public issue trackers, and how Signal and Slack changed their setups, with sources below. None of it is a bug in the detector. It is what happens when a correct rule is shipped as code instead of as a product.

Tip

A custom lint rule in a multi-team app is a small product. It needs evidence that it should exist, an owner people can reach, tests that survive lint rewriting your test code, a message treated as an API, a rollout that doesn’t punish people for legacy code, a speed budget, and a way to be switched off. Writing the detector is the cheapest part.

This is part 3 of the Quality Gates series. Part 2 picked the right tool for a rule and set up a shared lint config and baselines. This one is for whoever owns the rules — the platform or architecture engineer, the tech lead — and takes one rule from idea to ten teams, then asks what the iOS half of the same app can copy.

Table of contents

Open Table of contents

Before You Write It: The Admission Bar

Most rules that cause pain shouldn’t have been lint rules. Before writing one, it should clear four checks:

  1. Evidence. The same review comment at least three times, or an incident. “I’d prefer…” is a style guide entry, not a gate.
  2. No stronger layer fits. If the team owns the API, @Deprecated(level = ERROR, replaceWith = …) or a Kotlin opt-in marker enforces it at compile time with zero false positives. If it’s a dependency direction, a module fence does it (part 1). If a built-in check or an existing rule set covers it (part 2), promote that instead.
  3. An owner. A person or team who answers false-positive reports and upgrades the rule on every AGP bump.
  4. A fix a machine can suggest, or a message a human can act on. If the only possible message is “don’t do this”, the rule will be suppressed more than it’s obeyed.

android.util.Log clears all four: the comment is common, the API isn’t ours so we can’t deprecate it, a platform team owns logging, and the fix is mechanical. It’s also what Signal-Android enforces with SignalLogDetector, so there’s a real reference implementation to read alongside this one. (Signal’s code is AGPL-3.0; the sample below is written from scratch for this post.)

One Rule, End to End

A custom Android Lint rule is three pieces: an Issue (what gets reported), a Detector (how it’s found), and an IssueRegistry (how lint discovers them). They live in a plain JVM module — call it :lint-rules — that depends on com.android.tools.lint:lint-api at compileOnly scope, plus lint-tests for tests. Lint’s version is AGP’s plus 23: AGP 9.4 pairs with lint 32.4.

The detector

// lint-rules/src/main/kotlin/com/acme/lint/DirectAndroidLogDetector.kt
class DirectAndroidLogDetector : Detector(), SourceCodeScanner {

    // Lint calls visitMethodCall only for calls with these names — cheap pre-filter.
    override fun getApplicableMethodNames() = listOf("v", "d", "i", "w", "e", "wtf")

    override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
        // Resolved, not textual: an aliased import of our own Logger does not match.
        if (!context.evaluator.isMemberInClass(method, "android.util.Log")) return

        val receiver = node.receiver
        val fix = receiver?.let {
            fix().name("Replace with Logger")
                .replace()
                .range(context.getLocation(it))
                .with("com.acme.core.log.Logger")
                .shortenNames()
                .autoFix()
                .build()
        }
        context.report(
            ISSUE, node,
            context.getCallLocation(node, includeReceiver = true, includeArguments = false),
            "Using `android.util.Log` instead of `Logger` (unscrubbed output)",
            fix,
        )
    }

    companion object {
        @JvmField
        val ISSUE = Issue.create(
            id = "DirectAndroidLog",
            briefDescription = "Logging through android.util.Log",
            explanation = "`android.util.Log` writes messages as-is. `Logger` redacts phone " +
                "numbers and IDs first. False positive? Add a failing case to " +
                "lint-rules/src/test and tag #android-platform.",
            category = Category.SECURITY,
            priority = 8,
            severity = Severity.ERROR,
            implementation = Implementation(
                DirectAndroidLogDetector::class.java,
                Scope.JAVA_FILE_SCOPE,
            ),
        )
    }
}

Three details carry most of the value. isMemberInClass asks the type system, so the rule fires on android.util.Log.d(…), on a static import of d, and on Java callers, and it does not fire on a team class that happens to be called Log — exactly what a regex gets wrong. The quick fix is marked autoFix(), meaning it’s safe to apply unattended — true here only because Logger mirrors Log’s v/d/i/w/e/wtf signatures — so the lintFix Gradle task can migrate a legacy module in one commit. And the explanation says who owns the rule and how to complain, which matters more in week two than in week one.

The registry

// lint-rules/src/main/kotlin/com/acme/lint/TeamIssueRegistry.kt
class TeamIssueRegistry : IssueRegistry() {
    override val issues = listOf(DirectAndroidLogDetector.ISSUE)
    override val api = CURRENT_API
    override val vendor = Vendor(
        vendorName = "Acme Android Platform",
        identifier = "com.acme.lint",
        feedbackUrl = "https://github.com/acme/app/issues/new?labels=lint",
        contact = "#android-platform",
    )
}

Register it in src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry (one line: the class’s fully qualified name). The vendor block is not decoration: lint prints it next to every issue from this registry, in the IDE and in reports. It’s how a developer on another team finds out who to talk to without asking in a channel.

The tests — and the part that surprises everyone

class DirectAndroidLogDetectorTest {
    @Test
    fun `flags android util Log`() {
        TestLintTask.lint()
            .files(
                androidLogStub,
                kotlin(
                    """
                    package com.acme.chat
                    import android.util.Log
                    fun send(peer: String) {
                        Log.d("Chat", "sending to " + peer)
                    }
                    """
                ).indented(),
            )
            .issues(DirectAndroidLogDetector.ISSUE)
            .allowMissingSdk()
            .run()
            .expectErrorCount(1)
    }

    @Test
    fun `ignores our own Logger imported as Log`() {
        TestLintTask.lint()
            .files(
                loggerStub,
                kotlin(
                    """
                    package com.acme.chat
                    import com.acme.core.log.Logger as Log
                    fun send() { Log.d("Chat", "ok") }
                    """
                ).indented(),
            )
            .issues(DirectAndroidLogDetector.ISSUE)
            .allowMissingSdk()
            .run()
            .expectClean()
    }
}

androidLogStub and loggerStub are small source files declaring android.util.Log and the team Logger, so the test doesn’t need a real SDK — the same approach Signal’s SignalLogDetectorTest takes. Add .expectFixDiffs(…) to pin the quick fix’s exact edit.

What surprises first-time rule authors is that lint doesn’t run your test once. By default, lint-tests re-runs every test under a set of test modes that rewrite your test source: fully qualifying names, adding import aliases, inserting parentheses, reordering arguments, converting if to when, and more. A detector that compares node.methodName strings or walks the tree by position passes in the default mode and fails in three others. That’s not flakiness — it’s the test framework showing you the code your colleagues will actually write. It’s also why, if you let a coding agent draft detectors, lint’s test suite is a genuinely strong oracle: write the positive and negative fixtures yourself, and let the agent iterate until every mode passes. (That’s a practice worth trying, not an industry norm I can point to.)

What the rule can’t do

The same afternoon someone will ask for the next step: flag any log call that includes a token. Be careful here. Lint’s data-flow analyzer works within one function; values that escape through a return, a field or a call to another module are out of reach, and the analyzer’s default configuration ignores calls that look like logging calls. A rule like “token.value must never reach a logger” needs interprocedural taint analysis — CodeQL territory, not lint.

What works in practice is the split Signal uses: lint guarantees every log line goes through one chokepoint, and the chokepoint scrubs at runtime — Signal’s Scrubber redacts phone numbers, UUIDs, group IDs and more before a line is written. Types help from the other side: an AccessToken value class whose toString() returns "AccessToken(***)" is safe in a string template without any rule. And be honest in the explanation text: a lint rule stops a mistake from recurring in the syntactic shape you know, not in every shape. A determined "t=" + token.value passes it.

If You Last Wrote a Lint Check Before 2025

The ground moved under custom checks. Since AGP 9.3, lint always uses the K2 Kotlin frontend for Kotlin 2 projects (“Lint K2 is always enabled in AGP 9.3 and above”), and it was already selected automatically for Kotlin 2 projects well before that. If your checks predate it:

Same Rule, Three Engines

Part 2 argued the tool follows from what the rule needs. Seeing one rule in three engines makes that concrete.

Detekt. A syntax-only Detekt rule (1.23 API; the 2.0 alphas change it), in the shape of Element X’s RunCatchingRule:

class DirectAndroidLogRule(config: Config) : Rule(config) {
    override val issue = Issue(
        id = "DirectAndroidLog",
        severity = Severity.Security,
        description = "Use Logger instead of android.util.Log.",
        debt = Debt.FIVE_MINS,
    )

    override fun visitImportDirective(importDirective: KtImportDirective) {
        super.visitImportDirective(importDirective)
        if (importDirective.importedFqName?.asString() == "android.util.Log") {
            report(CodeSmell(issue, Entity.from(importDirective), "Import Logger, not android.util.Log"))
        }
    }
}

It’s fast and runs on any Kotlin source set, including KMP. It also misses a fully qualified android.util.Log.d(…) call, has no quick fix, and never shows up in Android Studio. The type-aware alternative — Detekt’s built-in ForbiddenMethodCall configured with android.util.Log.d and friends — closes the first gap but only runs in the slower type-resolution tasks.

Konsist. As a test, in the style of Element X’s architecture tests:

@Test
fun `production code does not import android util Log`() {
    Konsist.scopeFromProduction()
        .files
        .assertFalse { file -> file.hasImport { it.name == "android.util.Log" } }
}

Readable by anyone who reads tests; same blind spot on fully qualified calls; no IDE feedback. (Mind the release gap part 2 noted: no Konsist release since December 2024.) Konsist earns its keep on structural rules like Element X’s “a Presenter must not depend on another presenter”, not on call-site rules like this one.

Android LintDetekt (syntax)Konsist
Resolves typesalwaysonly in type-resolution tasksno
IDE squiggle + quick fixyes, built into Android Studioseparate IDE plugin; no quick fixno
KMP common source setsnoyesyes
Ships with a libraryyes (lintPublish)nono
Cost to runhighestlowesttest-suite time

For android.util.Log, Android Lint wins on every column that matters. For a complexity threshold in a KMP module, Detekt does. Pick per rule.

Getting the Rule Into Every Module

Two wiring mistakes cost more than any detector bug.

Wire rules through build logic, not per module. Signal does both: its signal-library convention plugin adds lintChecks(project(":lintchecks")), and about a dozen module build files, the app module among them, repeat the line at the time of writing. Per-module lines work while one team owns the build; in a multi-team app, a new module created from a template that lacks the line and doesn’t apply the plugin silently runs without the team’s rules. Put lintChecks in the convention plugin that every Android module applies, the way Now in Android applies its lint config.

Let API owners ship their rules with the API. lintChecks runs checks on this module; lintPublish packages them into this library’s AAR so they run in every consumer. In a multi-team app, that’s the difference between “the platform team owns all rules” and “the team that owns the design system owns the design-system rules” — Now in Android’s core/designsystem ships its detector this way, as AndroidX’s Compose runtime does. Rules then version with the API they protect.

Scope severity by folder when teams differ. A lint.xml inside a source folder applies to that folder and below (since lint 4.2), and <ignore path="…"> entries scope exceptions to paths. Slack’s iOS team used the same shape with SwiftLint during its modernization: stricter rules only in the directories holding “modern” modules (Slack Engineering).

Rolling Out Without a Revolt

Legacy code: baseline plus error, from day one

The usual advice is “ship as a warning, promote to error in a month”. In practice warnings don’t break builds and nobody reads CI warnings, so the month ends with the same violation count and a surprise. For rules about new code, the kinder and stricter option is: generate a baseline per module so existing violations are recorded, and ship the rule as error immediately. Nobody is blocked by code they didn’t write; nobody can add a new violation.

A warning phase still has a job — as a measurement trial when you don’t know the false-positive rate. Give it exit criteria up front: “error on 1 October if fewer than N false-positive reports and every report answered within two days”. Then it’s a trial, not a countdown.

Make the baseline shrink

A baseline that only grows is debt with no repayment plan. Lint already has the ratchet: since lint 8.4, fixed baseline entries are reported as a separate issue, LintBaselineFixed. Raise its severity to error in the shared lint.xml, and a PR that fixes a legacy violation must also remove it from the baseline — the file can shrink, never silently rot. Pair it with one CI check: a feature PR may not add lines to any lint-baseline.xml. Regenerating a baseline is a deliberate platform-team PR, not a way to get green.

Treat the message as an API

Lint matches baseline entries on issue ID, file and message text, not line numbers (API guide). That’s what makes baselines survive edits — and it’s why the “improved wording” in the opening broke every PR at once. Either freeze messages once a rule ships, or override Detector.sameMessage(…) (added in lint 8.3, API changes) to tell lint that the old and new wording mean the same issue. Detekt has the equivalent problem in a worse form: its baseline IDs can include rule-specific details, and editing legacy code can make an old entry stop matching.

Suppression: allowed, counted, occasionally forbidden

Suppression is sometimes correct, so don’t forbid it by default — count it. Slack “tracked instances where the linting errors had been disabled, so we could encourage developers to clean them up later”. A grep -c over @SuppressLint("DirectAndroidLog") per module, charted weekly, is enough.

For a small set of security or compliance rules, go further: setting an issue’s suppressNames to an empty collection makes it unsuppressible — no annotation, comment, lint.xml or baseline can silence it. Detekt’s ForbiddenSuppress does the reverse, forbidding @Suppress for listed rules. Use either sparingly: an unsuppressible rule with a false positive blocks a release, so its false-positive rate must be close to zero before it earns that status.

And when a team keeps routing around a rule — a LogHelper that wraps android.util.Log, a suppression per file — read it as a signal that the invariant is at the wrong layer. Move it into the module graph (the logging implementation lives in one module nobody else can depend on) or into a type, and delete the lint rule.

The Speed Budget

Lint is the most expensive check most Android CI pipelines run, and slowness is the complaint that erodes goodwill fastest. The cheap wins come first, all in the performance-tuning notes and lint guide: lint one variant (lintDebug) rather than lint; lint per module so Gradle can cache results for untouched modules (partial analysis, since lint 7.0); skip test and generated sources; give lint enough heap.

Partial analysis has a consequence for rule authors: lint analyzes each module separately and merges results later, so a detector that needs to know the app’s minSdk or usages in other modules must report provisionally and let the merge step decide. A detector that ignores this can behave differently in CI than in the IDE, which analyzes globally. The lint-tests partial test mode catches it.

Signal went further. In June 2026 it added a separate linter, fast-lint, that reimplements most of its custom rules on raw parse trees — in its own words, “parse-only: there is no symbol resolution or classpath, so receiver classes are resolved syntactically via the import table.” Its CI workflow now reads: “Pull requests run the fast custom linter (ciRemote); pushes to main / 8.x branches run the full Android lint (qaRemote).”

That’s a tiered gate: a cheap approximation on every PR, the full type-aware check before anything ships. It’s a sound pattern, and it has a real price that Signal is visibly paying: most custom rules now exist twice, in two engines with different blind spots, and both must be kept in agreement — while the three detectors without a fast copy only catch a PR once it reaches main. Signal publishes no timing numbers for the change, so the only honest lesson is qualitative: if lint time on PRs becomes the team’s top complaint, tiering is a legitimate answer — after the cheap wins, and with a named owner for the second copy.

The Rule Contract

Everything above condenses into a checklist. A rule doesn’t merge into the shared lint module until it has:

FieldExample for DirectAndroidLog
Evidencereview comment ×5 in Q3; one log-scrubbing incident
OwnerAndroid platform team, via the vendor block and CODEOWNERS on lint-rules/
Stronger layer ruled outthird-party API — can’t deprecate or opt-in-gate it
Testspositive, negative (aliased Logger), quick-fix diff; all test modes pass
Message + fixstates what, why, what instead; autoFix() quick fix
Severity + rollouterror from day one, baseline per module; LintBaselineFixed = error
Suppression policyallowed with a comment; counted weekly
Exit metricbaseline entries trending to zero; false-positive reports < 2 per quarter
Kill switchseverity="ignore" in shared lint.xml, one PR, owner’s call

The last two rows are the ones teams skip and regret. A rule that can’t show after a quarter that it caught something real is a candidate for deletion, and every rule needs an off switch that doesn’t require a lint-module release.

Reporting Where People Look

Two cheap additions make the rules visible where the work happens.

SARIF into code scanning. Lint writes SARIF with sarifReport = true; uploading it to GitHub code scanning turns violations into annotations on the PR diff instead of a report nobody opens. Check the cost first: code scanning is free for public repositories, but private ones need GitHub Code Security on a Team or Enterprise plan.

Tell the agents. Element X Android’s AGENTS.md lists ./gradlew lint and ./gradlew ktlintFormat among the common tasks a coding agent is told about, and its PR rules say “Never use android.util.Log” — this post’s rule, written for agents. As more code arrives from agents, lint rules become their spec too — and an agent under pressure to go green will reach for @SuppressLint exactly like a human under deadline, which is one more reason suppressions should be counted.

The iOS Half: Same Thesis, Different Strongest Layer

A chat app like this usually has an iOS client built by other teams in the same organization, and the obvious question is whether any of this transfers. The thesis does. The tooling doesn’t, and the asymmetry is worth stating plainly.

Signal ships nine custom Android Lint detectors. Signal-iOS runs SwiftFormat and clang-format on changed files in pre-commit and CI, and no lint gate of its own. That isn’t neglect. On iOS, more of the same invariants are cheaper to enforce one layer up.

The same “use our logger” rule shows why. On Android, Signal enforces it with a type-aware detector and a quick fix. Element X iOS enforces it with a SwiftLint regex in its .swiftlint.yml:

custom_rules:
  print_deprecation:
    regex: "\\b(print)\\b"
    match_kinds: identifier
    message: "MXLog should be used instead of print()"
    severity: error

It’s the same invariant at a very different strength: no type information (a method on your own type that happens to be called print matches), no quick fix, and results that appear after a build rather than as you type.

CapabilityAndroidiOSWho’s ahead
Custom rule with resolved typesAndroid Lint, by defaultSwiftLint regex rules are text; Swift-coded rules need a Bazel-built SwiftLint and SwiftSyntax still has no types; type-aware checks are five built-in analyzer rules needing a clean-build compiler logAndroid
Live IDE feedback + quick fix for custom rulesyesresults after a build; swiftlint --fix in batchAndroid
Baselineper-module lint-baseline.xmlSwiftLint --baseline since 0.55; Wire instead ratchets thresholds down; Signal checks changed files onlyeven
Shared policy across teamsconvention pluginparent_config (can be a remote URL) + swiftlint_version pineven
Module graph as a gateGradle modulesSPM targets, internal import (Swift 6), package access (Swift 5.9), tuist inspect dependencieseven
Compiler as linterKotlin warnings-as-errorsSwift 6 data-race checking, per-group warning control (SE-0443)iOS
Dead code across moduleslint UnusedResources (resources)Periphery on the index store (declarations)split

So on iOS, move up the hierarchy sooner. Put the rule in a type (an AccessToken whose description redacts), in the module graph (storage outside the feature target’s dependencies), or in the compiler (Swift 6 language mode turns a whole class of threading review comments into errors), and keep SwiftLint regex rules as a cheap backstop. Telegram-iOS is the extreme case: no linter config at all, and -warnings-as-errors in the compiler options of hundreds of Bazel build files.

The rollout lessons transfer unchanged: pin the linter version (Wire pins SwiftLint 0.61.0 in its config and runs it in a Linux container on PRs that touch Swift), share one policy, give every custom rule an owner and a message that says what to do instead. What shouldn’t be copied is the custom-detector habit: unless the iOS build already runs on Bazel, building semantic SwiftLint rules costs a platform team, and a type or a target boundary does the job for free. Android teams can take something back too — iOS apps lean on the compiler, and Kotlin’s @RequiresOptIn, internal and warnings-as-errors are underused in app code compared with how iOS teams use Swift 6 mode.

What Lint Won’t Catch

The detector in this post is about sixty lines. The rest of the post is what makes those sixty lines survive ten teams — and it’s the part worth copying.

Sources

Android Lint

Real projects

iOS

Reporting


Share this post:

Part of the Quality Gates series


Previous Post
Android Lint, Detekt or ktlint? Ask What the Rule Needs