Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package dev.androidbroadcast.featured.lint

import com.android.tools.lint.client.api.UElementHandler
import com.android.tools.lint.detector.api.Category
import com.android.tools.lint.detector.api.Detector
import com.android.tools.lint.detector.api.Implementation
import com.android.tools.lint.detector.api.Issue
import com.android.tools.lint.detector.api.JavaContext
import com.android.tools.lint.detector.api.Scope
import com.android.tools.lint.detector.api.Severity
import com.intellij.psi.PsiClassType
import org.jetbrains.uast.UAnnotation
import org.jetbrains.uast.UElement
import org.jetbrains.uast.UVariable
import java.time.LocalDate
import java.time.format.DateTimeParseException

public class ExpiredFeatureFlagDetector :
Detector(),
Detector.UastScanner {
public companion object {
public val ISSUE: Issue =
Issue.create(
id = "ExpiredFeatureFlag",
briefDescription = "Feature flag has passed its expiry date and should be removed",
explanation = """
A `@ExpiresAt`-annotated `ConfigParam` property has passed its expiry date. \
Remove the flag and all code guarded by it to avoid accumulating stale flags.
""",
category = Category.CORRECTNESS,
priority = 5,
severity = Severity.WARNING,
implementation =
Implementation(
ExpiredFeatureFlagDetector::class.java,
Scope.JAVA_FILE_SCOPE,
),
)

private const val EXPIRES_AT_FQN = "dev.androidbroadcast.featured.ExpiresAt"
private const val CONFIG_PARAM_FQN = "dev.androidbroadcast.featured.ConfigParam"
}

override fun getApplicableUastTypes(): List<Class<out UElement>> = listOf(UAnnotation::class.java)

override fun createUastHandler(context: JavaContext): UElementHandler =
object : UElementHandler() {
// Kotlin @Target(PROPERTY, FIELD) causes UAST to visit the same KtAnnotationEntry
// twice: once as the Kotlin property annotation, once as the backing-field annotation.
// Both have non-null sourcePsi pointing to the same KtAnnotationEntry object.
// We deduplicate by tracking visited sourcePsi instances within this file handler.
private val visitedSourceElements = mutableSetOf<Any>()

override fun visitAnnotation(node: UAnnotation) {
// Only care about @ExpiresAt annotations.
val qualifiedName = node.qualifiedName ?: return
if (qualifiedName != EXPIRES_AT_FQN && !qualifiedName.endsWith(".ExpiresAt")) return

// Navigate up to the annotated UVariable (property or field).
val variable = node.uastParent as? UVariable ?: return

// Deduplicate: both Kotlin-property and backing-field visits share the same
// KtAnnotationEntry as sourcePsi. Track each sourcePsi and skip repeats.
val sourcePsi = node.sourcePsi ?: node.javaPsi ?: return
if (!visitedSourceElements.add(sourcePsi)) return

// Restrict to ConfigParam-typed properties, mirroring the Detekt counterpart.
val variableType = variable.type as? PsiClassType ?: return
val variableClass = variableType.resolve() ?: return
if (!context.evaluator.extendsClass(variableClass, CONFIG_PARAM_FQN, false)) return

// Extract the date string from the annotation's first positional argument.
val dateArg = node.findAttributeValue("date")?.evaluate() as? String ?: return

val expiryDate =
try {
LocalDate.parse(dateArg)
} catch (_: DateTimeParseException) {
// Malformed date — skip silently, do not crash.
return
}

// today.isAfter(expiryDate) matches Detekt semantics:
// past → today > expiryDate → report
// today → today == expiryDate → clean (not after)
// future → today < expiryDate → clean
if (!LocalDate.now().isAfter(expiryDate)) return

val flagName = variable.name ?: "unknown"
context.report(
issue = ISSUE,
scope = node,
location = context.getLocation(node),
message = "Feature flag '$flagName' expired on $dateArg. Remove the flag and its guarded code.",
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ import com.android.tools.lint.detector.api.Issue
* hosts without silently dropping all rules.
*/
public class FeaturedIssueRegistry : IssueRegistry() {
override val issues: List<Issue> = listOf(HardcodedFlagValueDetector.ISSUE)
override val issues: List<Issue> =
listOf(
HardcodedFlagValueDetector.ISSUE,
InvalidFlagReferenceDetector.ISSUE,
UncheckedFlagAccessDetector.ISSUE,
ExpiredFeatureFlagDetector.ISSUE,
)

override val api: Int = CURRENT_API

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package dev.androidbroadcast.featured.lint

import com.android.tools.lint.client.api.UElementHandler
import com.android.tools.lint.detector.api.Category
import com.android.tools.lint.detector.api.Detector
import com.android.tools.lint.detector.api.Implementation
import com.android.tools.lint.detector.api.Issue
import com.android.tools.lint.detector.api.JavaContext
import com.android.tools.lint.detector.api.Scope
import com.android.tools.lint.detector.api.Severity
import com.intellij.psi.PsiClassType
import org.jetbrains.uast.UAnnotation
import org.jetbrains.uast.UElement
import org.jetbrains.uast.UFile
import org.jetbrains.uast.UVariable
import org.jetbrains.uast.visitor.AbstractUastVisitor

/**
* Warns when `@BehindFlag` or `@AssumesFlag` references a flag name that has no matching
* `ConfigParam` property in the same file.
*
* The rule collects all variables/fields whose type or initializer resolves to [ConfigParam],
* then verifies every `@BehindFlag`/`@AssumesFlag` `flagName` argument matches one of those
* property names. If the file contains no `ConfigParam` declarations at all, the rule is
* skipped entirely to avoid false positives from generated code.
*/
public class InvalidFlagReferenceDetector :
Detector(),
Detector.UastScanner {
public companion object {
public val ISSUE: Issue =
Issue.create(
id = "InvalidFlagReference",
briefDescription = "`@BehindFlag` or `@AssumesFlag` references an unknown flag name",
explanation = """
The `flagName` argument does not match any `ConfigParam` property declared \
in the same file. This is likely a typo. \
Ensure the value exactly matches the property name of the corresponding \
`ConfigParam`.
""",
category = Category.CORRECTNESS,
priority = 7,
severity = Severity.WARNING,
implementation =
Implementation(
InvalidFlagReferenceDetector::class.java,
Scope.JAVA_FILE_SCOPE,
),
)

private const val CONFIG_PARAM_FQN = "dev.androidbroadcast.featured.ConfigParam"
private const val BEHIND_FLAG_FQN = "dev.androidbroadcast.featured.BehindFlag"
private const val ASSUMES_FLAG_FQN = "dev.androidbroadcast.featured.AssumesFlag"
private const val FLAG_NAME_ATTR = "flagName"
}

override fun getApplicableUastTypes(): List<Class<out UElement>> = listOf(UFile::class.java)

override fun createUastHandler(context: JavaContext): UElementHandler =
object : UElementHandler() {
override fun visitFile(node: UFile) {
// Pass 1: collect all property names whose type resolves to ConfigParam.
val knownFlags = mutableSetOf<String>()
node.accept(
object : AbstractUastVisitor() {
override fun visitVariable(node: UVariable): Boolean {
val name = node.name ?: return false
if (isConfigParam(context, node)) {
knownFlags += name
}
return false
}
},
)

// If the file declares no ConfigParam properties, skip validation entirely.
// This avoids false positives when flags are imported from generated code.
if (knownFlags.isEmpty()) return

// Pass 2: validate @BehindFlag / @AssumesFlag annotations.
node.accept(
object : AbstractUastVisitor() {
override fun visitAnnotation(node: UAnnotation): Boolean {
val fqn = node.qualifiedName ?: return false
if (fqn != BEHIND_FLAG_FQN && fqn != ASSUMES_FLAG_FQN) return false

val flagName =
node.findAttributeValue(FLAG_NAME_ATTR)?.evaluate() as? String
?: return false

if (flagName !in knownFlags) {
context.report(
issue = ISSUE,
scope = node,
location = context.getLocation(node),
message =
"Flag name '$flagName' does not match any `ConfigParam` " +
"property in this file. Known flags: ${knownFlags.sorted().joinToString()}.",
)
}
return false
}
},
)
}
}

/**
* Returns `true` if the variable's resolved type or initializer call refers to [ConfigParam].
*
* Two strategies are tried in order:
* 1. Explicit type annotation resolved via PsiClassType (most reliable).
* 2. Initializer call text heuristic — fallback for cases where type inference
* is not fully resolved in the lint sandbox (same approach as the Detekt rule,
* but only as a secondary check).
*/
private fun isConfigParam(
context: JavaContext,
variable: UVariable,
): Boolean {
// Strategy 1: check the declared/inferred type via PSI type resolution.
val psiType = variable.type as? PsiClassType
val resolvedClass = psiType?.resolve()
if (resolvedClass != null &&
context.evaluator.extendsClass(resolvedClass, CONFIG_PARAM_FQN, false)
) {
return true
}

// Strategy 2: heuristic on initializer text for cases where PSI resolution is absent
// (e.g. inferred type in generated stubs without full classpath). The Detekt rule uses
// the same fallback since it also lacks full type resolution.
val initText = variable.uastInitializer?.sourcePsi?.text ?: return false
return initText.trimStart().startsWith("ConfigParam")
}
}
Loading
Loading