Add a new ci task with faster lint.

This commit is contained in:
Greyson Parrelli 2026-06-04 13:05:55 -04:00 committed by Cody Henthorne
parent 53c4069c64
commit eae894152c
25 changed files with 1588 additions and 2 deletions

View File

@ -41,13 +41,15 @@ jobs:
# Required to persist the Gradle configuration cache across runs.
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
# Pull requests run the fast custom linter (ci); pushes to main / 8.x branches run the full
# Android lint (qa).
- name: Build with Gradle
env:
SIGNAL_BUILD_CACHE_URL: ${{ secrets.SIGNAL_BUILD_CACHE_URL }}
SIGNAL_BUILD_CACHE_USER: ${{ secrets.SIGNAL_BUILD_CACHE_USER }}
SIGNAL_BUILD_CACHE_PASSWORD: ${{ secrets.SIGNAL_BUILD_CACHE_PASSWORD }}
SIGNAL_BUILD_CACHE_PUSH: ${{ startsWith(github.ref, 'refs/heads/8.') }}
run: ./gradlew qa
run: ./gradlew ${{ github.event_name == 'pull_request' && 'ci' || 'qa' }}
- name: Archive reports for failed build
if: ${{ failure() }}

View File

@ -73,7 +73,13 @@ tasks.register("buildQa") {
tasks.register("qa") {
group = "Verification"
description = "Quality Assurance. Run before pushing."
description = "Quality Assurance. Run before release."
dependsOn("clean")
}
tasks.register("ci") {
group = "Verification"
description = "Faster version of qa that's intended to be run on PRs. Uses a :fast-lint instead of full lint."
dependsOn("clean")
}
@ -109,6 +115,26 @@ gradle.projectsEvaluated {
}
}
tasks.named("ci") {
dependsOn("ktlintCheck")
dependsOn("buildQa")
dependsOn("checkStopship")
dependsOn(appTestTask)
appCompileInstrumentationTask?.let { dependsOn(it) }
dependsOn(":fast-lint:fastLint")
subprojects.forEach { subproject ->
subproject.tasks.findByName("ktlintCheck")?.let { dependsOn(it) }
}
subprojects.filter { it.name != "Signal-Android" }.forEach { subproject ->
val testTask = subproject.tasks.findByName("testDebugUnitTest") ?: subproject.tasks.findByName("test")
testTask?.let { dependsOn(it) }
}
}
// Ensure clean runs before everything else
rootProject.allprojects.forEach { project ->
project.tasks.matching { it.name != "clean" }.configureEach {

View File

@ -0,0 +1,39 @@
plugins {
id("java-library")
id("org.jetbrains.kotlin.jvm")
}
java {
sourceCompatibility = JavaVersion.toVersion(libs.versions.javaVersion.get())
targetCompatibility = JavaVersion.toVersion(libs.versions.javaVersion.get())
}
kotlin {
jvmToolchain {
languageVersion = JavaLanguageVersion.of(libs.versions.kotlinJvmTarget.get())
}
}
dependencies {
implementation(lintLibs.intellij.core)
implementation(lintLibs.kotlin.compiler)
implementation(libs.google.guava.android)
testImplementation(testLibs.junit.junit)
}
tasks.register<JavaExec>("fastLint") {
group = "Verification"
description = "Runs the fast custom AST linter (:fast-lint) over the whole repository. Fails on any finding."
mainClass.set("org.signal.fastlint.Lint")
classpath = sourceSets["main"].runtimeClasspath
maxHeapSize = "2g"
// Strings resolved at configuration time so the task is configuration-cache compatible.
val repoRoot = rootProject.projectDir.absolutePath
val reportPath = layout.buildDirectory.file("reports/fast-lint/findings.txt").get().asFile.absolutePath
args(repoRoot, "--report=$reportPath")
// A linter should run every time, not be skipped as up-to-date.
outputs.upToDateWhen { false }
}

View File

@ -0,0 +1,329 @@
package org.signal.fastlint
import com.intellij.lang.java.JavaLanguage
import com.intellij.lang.java.syntax.JavaElementTypeConverterExtension
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.platform.syntax.psi.CommonElementTypeConverterFactory
import com.intellij.platform.syntax.psi.ElementTypeConverters
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFileFactory
import com.intellij.psi.PsiJavaFile
import com.intellij.psi.PsiMethodCallExpression
import com.intellij.psi.PsiNewExpression
import com.intellij.psi.PsiReferenceExpression
import com.intellij.psi.JavaRecursiveElementVisitor
import com.intellij.psi.PsiClass
import org.jetbrains.kotlin.K1Deprecation
import org.jetbrains.kotlin.cli.common.messages.MessageCollector
import org.jetbrains.kotlin.cli.jvm.compiler.EnvironmentConfigFiles
import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment
import org.jetbrains.kotlin.config.CommonConfigurationKeys
import org.jetbrains.kotlin.config.CompilerConfiguration
import org.jetbrains.kotlin.psi.KtCallExpression
import org.jetbrains.kotlin.psi.KtClassOrObject
import org.jetbrains.kotlin.psi.KtPsiFactory
import org.jetbrains.kotlin.psi.KtSimpleNameExpression
import org.jetbrains.kotlin.psi.KtTreeVisitorVoid
import org.signal.fastlint.rules.ALL_RULES
import java.io.File
import java.io.StringReader
import javax.xml.stream.XMLInputFactory
import javax.xml.stream.XMLStreamConstants
import javax.xml.stream.XMLStreamException
/** Aggregate result of a repository scan. */
data class RunResult(
val kotlinFiles: Int,
val javaFiles: Int,
val xmlFiles: Int,
val findings: List<Finding>
)
/**
* The fast-lint engine. Sets up the Kotlin/Java PSI front-ends once, partitions the registered
* [rules] by the node types they handle, and analyzes Kotlin, Java, and XML sources. Reusable across
* many files; not thread-safe (PSI parsing is single-threaded). Close to release the parser.
*/
class FastLint(rules: List<Rule> = ALL_RULES) : AutoCloseable {
private val ktCallRules = rules.filterIsInstance<KtCallRule>()
private val ktNameRules = rules.filterIsInstance<KtNameRule>()
private val ktClassRules = rules.filterIsInstance<KtClassRule>()
private val javaCallRules = rules.filterIsInstance<JavaCallRule>()
private val javaNewRules = rules.filterIsInstance<JavaNewRule>()
private val javaReferenceRules = rules.filterIsInstance<JavaReferenceRule>()
private val javaClassRules = rules.filterIsInstance<JavaClassRule>()
private val xmlElementRules = rules.filterIsInstance<XmlElementRule>()
private val xmlStringResourceRules = rules.filterIsInstance<XmlStringResourceRule>()
private val disposable: Disposable = Disposer.newDisposable()
private val ktFactory: KtPsiFactory
private val psiFactory: PsiFileFactory
init {
val env = createKotlinEnvironment(disposable)
registerJavaConverter(disposable)
ktFactory = KtPsiFactory(env.project, markGenerated = false)
psiFactory = PsiFileFactory.getInstance(env.project)
}
fun run(root: File): RunResult {
var kotlin = 0
var java = 0
var xml = 0
val findings = ArrayList<Finding>()
for (file in sourceFiles(root)) {
val source = file.readText()
when (file.extension) {
"kt" -> {
findings.addAll(analyzeKotlin(file, source))
kotlin++
}
"java" -> {
findings.addAll(analyzeJava(file, source))
java++
}
"xml" -> {
findings.addAll(analyzeXml(file, source))
xml++
}
}
}
return RunResult(kotlin, java, xml, findings)
}
fun analyzeKotlin(file: File, source: String): List<Finding> {
val ktFile = ktFactory.createFile(file.name, source)
val context = KotlinFileContext(file, ktFile)
val findings = ArrayList<Finding>()
val reporter = SuppressingReporter(file, findings)
ktFile.accept(object : KtTreeVisitorVoid() {
override fun visitCallExpression(expression: KtCallExpression) {
super.visitCallExpression(expression)
for (rule in ktCallRules) rule.onCall(expression, context, reporter)
}
override fun visitSimpleNameExpression(expression: KtSimpleNameExpression) {
super.visitSimpleNameExpression(expression)
for (rule in ktNameRules) rule.onName(expression, context, reporter)
}
override fun visitClassOrObject(classOrObject: KtClassOrObject) {
super.visitClassOrObject(classOrObject)
for (rule in ktClassRules) rule.onClass(classOrObject, context, reporter)
}
})
return findings
}
fun analyzeJava(file: File, source: String): List<Finding> {
val javaFile = psiFactory.createFileFromText(file.name, JavaLanguage.INSTANCE, source) as? PsiJavaFile ?: return emptyList()
val context = JavaFileContext(file, javaFile, source)
val findings = ArrayList<Finding>()
val reporter = SuppressingReporter(file, findings)
javaFile.accept(object : JavaRecursiveElementVisitor() {
override fun visitMethodCallExpression(call: PsiMethodCallExpression) {
super.visitMethodCallExpression(call)
for (rule in javaCallRules) rule.onCall(call, context, reporter)
}
override fun visitNewExpression(expression: PsiNewExpression) {
super.visitNewExpression(expression)
for (rule in javaNewRules) rule.onNew(expression, context, reporter)
}
override fun visitReferenceExpression(expression: PsiReferenceExpression) {
super.visitReferenceExpression(expression)
for (rule in javaReferenceRules) rule.onReference(expression, context, reporter)
}
override fun visitClass(aClass: PsiClass) {
super.visitClass(aClass)
for (rule in javaClassRules) rule.onClass(aClass, context, reporter)
}
})
return findings
}
fun analyzeXml(file: File, source: String): List<Finding> {
val path = file.path
val isLayout = "/res/layout" in path
val isValues = VALUES_FILE.containsMatchIn(path)
if (!isLayout && !isValues) {
return emptyList()
}
if (xmlElementRules.isEmpty() && xmlStringResourceRules.isEmpty()) {
return emptyList()
}
val context = XmlFileContext(file, isLayout, isValues)
val findings = ArrayList<Finding>()
val ignoreStack = ArrayDeque<Set<String>>()
val reader = XML_INPUT_FACTORY.createXMLStreamReader(StringReader(source))
try {
var inString = false
var stringName: String? = null
var stringLine = 0
var stringIgnores: Set<String> = emptySet()
val stringText = StringBuilder()
while (reader.hasNext()) {
when (reader.next()) {
XMLStreamConstants.START_ELEMENT -> {
val line = reader.location.lineNumber
val here = HashSet<String>()
val attributes = ArrayList<XmlAttribute>(reader.attributeCount)
for (i in 0 until reader.attributeCount) {
val prefix = reader.getAttributePrefix(i) ?: ""
val local = reader.getAttributeLocalName(i)
val value = reader.getAttributeValue(i)
attributes.add(XmlAttribute(prefix, local, value))
if (prefix == "tools" && local == "ignore") {
value.split(',').forEach { here.add(it.trim()) }
}
}
if (xmlElementRules.isNotEmpty()) {
val element = XmlStartElement(reader.localName, line, attributes)
val sink = IgnoringXmlSink(file, findings, here, ignoreStack)
for (rule in xmlElementRules) rule.onStartElement(element, context, sink)
}
if (isValues && reader.localName == "string" && xmlStringResourceRules.isNotEmpty()) {
inString = true
stringName = attributes.firstOrNull { it.localName == "name" }?.value
stringLine = line
stringIgnores = unionIgnores(here, ignoreStack)
stringText.setLength(0)
}
ignoreStack.addLast(here)
}
XMLStreamConstants.CHARACTERS -> {
if (inString) {
stringText.append(reader.text)
}
}
XMLStreamConstants.END_ELEMENT -> {
if (inString && reader.localName == "string") {
val sink = SetIgnoringXmlSink(file, findings, stringIgnores)
for (rule in xmlStringResourceRules) rule.onStringResource(stringName, stringText.toString(), stringLine, context, sink)
inString = false
}
ignoreStack.removeLast()
}
}
}
} catch (e: XMLStreamException) {
// Skip files that aren't well-formed standalone XML (e.g. fragments); lint skips these too.
} finally {
reader.close()
}
return findings
}
override fun close() = Disposer.dispose(disposable)
private class SuppressingReporter(val file: File, val out: MutableList<Finding>) : Reporter {
override fun report(checkId: String, element: PsiElement, line: Int, message: String) {
if (!isSuppressed(element, checkId)) {
out.add(Finding(checkId, file, line, message))
}
}
}
private class IgnoringXmlSink(
val file: File,
val out: MutableList<Finding>,
val here: Set<String>,
val ancestors: ArrayDeque<Set<String>>
) : XmlSink {
override fun report(checkId: String, line: Int, message: String) {
if (here.contains(checkId) || here.contains("all")) {
return
}
if (ancestors.any { it.contains(checkId) || it.contains("all") }) {
return
}
out.add(Finding(checkId, file, line, message))
}
}
private class SetIgnoringXmlSink(val file: File, val out: MutableList<Finding>, val ignores: Set<String>) : XmlSink {
override fun report(checkId: String, line: Int, message: String) {
if (ignores.contains(checkId) || ignores.contains("all")) {
return
}
out.add(Finding(checkId, file, line, message))
}
}
companion object {
private val VALUES_FILE = Regex("/res/values/.*\\.xml$")
private val EXCLUDED_DIRS = setOf("build", ".git", ".gradle", ".idea", "test", "androidTest", "testFixtures")
// Vendored third-party forks (Glide, PhotoView) are not held to Signal conventions.
private val EXCLUDED_MODULE_PATHS = listOf("lib/glide/", "lib/photoview/")
private val XML_INPUT_FACTORY: XMLInputFactory = XMLInputFactory.newInstance().apply {
setProperty(XMLInputFactory.IS_NAMESPACE_AWARE, true)
setProperty(XMLInputFactory.SUPPORT_DTD, false)
}
private fun unionIgnores(here: Set<String>, ancestors: ArrayDeque<Set<String>>): Set<String> {
if (here.isEmpty() && ancestors.all { it.isEmpty() }) {
return emptySet()
}
val result = HashSet(here)
ancestors.forEach { result.addAll(it) }
return result
}
/**
* KotlinCoreEnvironment registers the Kotlin token->IElementType converter for the new
* platform.syntax framework, but not Java's, so parsing Java source otherwise fails with
* "IElementType for token WHITE_SPACE is missing". Register the common + Java converters ourselves.
*/
private fun registerJavaConverter(disposable: Disposable) {
val converters = ElementTypeConverters.instance
converters.addExplicitExtension(JavaLanguage.INSTANCE, CommonElementTypeConverterFactory(), disposable)
converters.addExplicitExtension(JavaLanguage.INSTANCE, JavaElementTypeConverterExtension(), disposable)
}
// createForProduction is K1 API (opt-in, slated to become an error in Kotlin 2.3). We
// deliberately use the parse-only K1 PSI environment (as ktlint does); migrating to the
// Analysis API is unnecessary for syntax-only checks.
@OptIn(K1Deprecation::class)
private fun createKotlinEnvironment(disposable: Disposable): KotlinCoreEnvironment {
val config = CompilerConfiguration().apply {
put(CommonConfigurationKeys.MODULE_NAME, "fastlint")
put(CommonConfigurationKeys.MESSAGE_COLLECTOR_KEY, MessageCollector.NONE)
}
return KotlinCoreEnvironment.createForProduction(disposable, config, EnvironmentConfigFiles.JVM_CONFIG_FILES)
}
fun sourceFiles(root: File): List<File> {
val rootPath = root.path
val out = ArrayList<File>(16384)
root.walkTopDown()
.onEnter { it.name !in EXCLUDED_DIRS }
.forEach { f ->
if (f.isFile && "/src/" in f.path && "/test/" !in f.path && "/androidTest/" !in f.path) {
val relative = f.path.removePrefix(rootPath).trimStart('/')
if (EXCLUDED_MODULE_PATHS.any { relative.startsWith(it) }) {
return@forEach
}
when (f.extension) {
"kt", "java", "xml" -> out.add(f)
}
}
}
return out
}
}
}

View File

@ -0,0 +1,11 @@
package org.signal.fastlint
import java.io.File
/** A single issue reported by a [Rule]. */
data class Finding(
val checkId: String,
val file: File,
val line: Int,
val message: String
)

View File

@ -0,0 +1,59 @@
@file:JvmName("Lint")
package org.signal.fastlint
import java.io.File
import kotlin.system.exitProcess
/**
* A lightweight linter that does real AST traversal of Kotlin (PSI) and Java (PSI) plus streaming
* traversal of XML resources, using the same parser front-ends that power lint/ktlint.
*
* Note that this lint is parse-only: there is no symbol resolution or classpath, so receiver classes
* are resolved syntactically via the import table.
*
* Suppression honors @SuppressLint/@SuppressWarnings/@Suppress in code and tools:ignore in XML.
*
* Usage: Lint <repo-root> [--report=<file>]
*/
fun main(args: Array<String>) {
val root = File(args.firstOrNull { !it.startsWith("--") } ?: ".").absoluteFile
val reportPath = args.firstOrNull { it.startsWith("--report=") }?.substringAfter('=')
val start = System.nanoTime()
val result = FastLint().use { it.run(root) }
val elapsedMs = (System.nanoTime() - start) / 1_000_000
val sorted = result.findings.sortedWith(compareBy({ it.file.path }, { it.line }, { it.checkId }))
val rootPrefix = root.path + "/"
val sb = StringBuilder()
sb.appendLine("fast-lint: scanned ${result.kotlinFiles} Kotlin + ${result.javaFiles} Java + ${result.xmlFiles} XML files in ${elapsedMs}ms")
sb.appendLine()
if (sorted.isEmpty()) {
sb.appendLine("No issues found.")
} else {
sb.appendLine("Issues by check:")
sorted.groupingBy { it.checkId }.eachCount().toSortedMap().forEach { (id, count) ->
sb.appendLine(" %-34s %d".format(id, count))
}
sb.appendLine(" %-34s %d".format("TOTAL", sorted.size))
sb.appendLine()
sb.appendLine("Issues:")
sorted.forEach {
sb.appendLine(" ${it.file.path.removePrefix(rootPrefix)}:${it.line}: ${it.checkId}: ${it.message}")
}
}
val output = sb.toString()
print(output)
if (reportPath != null) {
File(reportPath).apply { parentFile?.mkdirs() }.writeText(output)
}
if (sorted.isNotEmpty()) {
System.err.println("\nfast-lint found ${sorted.size} issue(s). Suppress legitimate cases with @SuppressLint(\"<CheckId>\") or tools:ignore.")
exitProcess(1)
}
exitProcess(0)
}

View File

@ -0,0 +1,174 @@
package org.signal.fastlint
import com.intellij.psi.PsiAnnotation
import com.intellij.psi.PsiClass
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiJavaFile
import com.intellij.psi.PsiMethodCallExpression
import com.intellij.psi.PsiModifierListOwner
import com.intellij.psi.PsiNewExpression
import com.intellij.psi.PsiReferenceExpression
import org.jetbrains.kotlin.psi.KtCallExpression
import org.jetbrains.kotlin.psi.KtClassOrObject
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.psi.KtModifierListOwner
import org.jetbrains.kotlin.psi.KtSimpleNameExpression
import java.io.File
/**
* Marker for a fast-lint rule. A rule implements one or more of the capability interfaces below
* (e.g. [KtCallRule], [JavaClassRule], [XmlElementRule]) for the node types it cares about. The
* engine partitions registered rules by capability, so a rule is only invoked for the node types
* it actually handles. Rules are stateless and registered in [org.signal.fastlint.rules.ALL_RULES].
*/
interface Rule
interface KtCallRule : Rule {
fun onCall(call: KtCallExpression, context: KotlinFileContext, reporter: Reporter)
}
interface KtNameRule : Rule {
fun onName(name: KtSimpleNameExpression, context: KotlinFileContext, reporter: Reporter)
}
interface KtClassRule : Rule {
fun onClass(klass: KtClassOrObject, context: KotlinFileContext, reporter: Reporter)
}
interface JavaCallRule : Rule {
fun onCall(call: PsiMethodCallExpression, context: JavaFileContext, reporter: Reporter)
}
interface JavaNewRule : Rule {
fun onNew(expression: PsiNewExpression, context: JavaFileContext, reporter: Reporter)
}
interface JavaReferenceRule : Rule {
fun onReference(reference: PsiReferenceExpression, context: JavaFileContext, reporter: Reporter)
}
interface JavaClassRule : Rule {
fun onClass(klass: PsiClass, context: JavaFileContext, reporter: Reporter)
}
interface XmlElementRule : Rule {
fun onStartElement(element: XmlStartElement, context: XmlFileContext, sink: XmlSink)
}
interface XmlStringResourceRule : Rule {
fun onStringResource(name: String?, value: String, line: Int, context: XmlFileContext, sink: XmlSink)
}
/** Reports findings for code (Kotlin/Java) rules. Handles @Suppress/@SuppressLint suppression. */
interface Reporter {
fun report(checkId: String, element: PsiElement, line: Int, message: String)
}
/** Reports findings for XML rules. Handles tools:ignore suppression. */
interface XmlSink {
fun report(checkId: String, line: Int, message: String)
}
/** Per-file context for Kotlin rules: the imports table and offset->line / receiver resolution. */
class KotlinFileContext(val file: File, val ktFile: KtFile) {
val imports: Map<String, String> = buildKotlinImports(ktFile)
fun lineOf(offset: Int): Int = lineNumberOf(ktFile.text, offset)
fun resolveFqn(receiver: String): String = resolveReceiver(receiver, imports)
}
/** Per-file context for Java rules. */
class JavaFileContext(val file: File, val javaFile: PsiJavaFile, val text: CharSequence) {
val imports: Map<String, String> = buildJavaImports(javaFile)
fun lineOf(offset: Int): Int = lineNumberOf(text, offset)
fun resolveFqn(receiver: String): String = resolveReceiver(receiver, imports)
}
/** Per-file context for XML rules. */
class XmlFileContext(val file: File, val isLayout: Boolean, val isValues: Boolean)
class XmlAttribute(val prefix: String, val localName: String, val value: String)
class XmlStartElement(val localName: String, val line: Int, val attributes: List<XmlAttribute>)
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
private val SUPPRESS_ANNOTATIONS = setOf("Suppress", "SuppressLint", "SuppressWarnings")
internal fun lineNumberOf(text: CharSequence, offset: Int): Int {
var line = 1
val end = minOf(offset, text.length)
var i = 0
while (i < end) {
if (text[i] == '\n') {
line++
}
i++
}
return line
}
/** Resolves a receiver like "Log" or "Build.VERSION_CODES" to a fully-qualified name via imports. */
internal fun resolveReceiver(receiver: String, imports: Map<String, String>): String {
val head = receiver.substringBefore('.')
val mapped = imports[head] ?: return receiver
return if ('.' in receiver) {
mapped + receiver.substring(receiver.indexOf('.'))
} else {
mapped
}
}
private fun buildKotlinImports(ktFile: KtFile): Map<String, String> {
val imports = HashMap<String, String>()
ktFile.importList?.imports?.forEach { imp ->
val fq = imp.importedFqName?.asString() ?: return@forEach
imports[imp.aliasName ?: fq.substringAfterLast('.')] = fq
}
return imports
}
private fun buildJavaImports(javaFile: PsiJavaFile): Map<String, String> {
val imports = HashMap<String, String>()
javaFile.importList?.importStatements?.forEach { imp ->
val qn = imp.qualifiedName ?: return@forEach
if (!imp.isOnDemand) {
imports[qn.substringAfterLast('.')] = qn
}
}
return imports
}
/** True if any @Suppress/@SuppressLint/@SuppressWarnings on the element or an ancestor names checkId. */
internal fun isSuppressed(element: PsiElement, checkId: String): Boolean {
val needle = "\"$checkId\""
var current: PsiElement? = element
while (current != null) {
when (val node = current) {
is KtModifierListOwner ->
for (entry in node.annotationEntries) {
val name = entry.shortName?.asString() ?: continue
if (name in SUPPRESS_ANNOTATIONS) {
val args = entry.valueArgumentList?.text ?: ""
if (args.contains(needle) || args.contains("\"all\"")) {
return true
}
}
}
is PsiModifierListOwner ->
for (annotation: PsiAnnotation in node.modifierList?.annotations.orEmpty()) {
val name = annotation.nameReferenceElement?.referenceName ?: continue
if (name in SUPPRESS_ANNOTATIONS) {
val args = annotation.parameterList.text
if (args.contains(needle) || args.contains("\"all\"")) {
return true
}
}
}
}
current = current.parent
}
return false
}

View File

@ -0,0 +1,46 @@
package org.signal.fastlint.rules
import com.intellij.psi.PsiNewExpression
import org.jetbrains.kotlin.psi.KtCallExpression
import org.jetbrains.kotlin.psi.KtDotQualifiedExpression
import org.jetbrains.kotlin.psi.KtNameReferenceExpression
import org.jetbrains.kotlin.psi.psiUtil.getQualifiedExpressionForSelector
import org.signal.fastlint.JavaFileContext
import org.signal.fastlint.JavaNewRule
import org.signal.fastlint.KotlinFileContext
import org.signal.fastlint.KtCallRule
import org.signal.fastlint.Reporter
/** Flags framework/appcompat AlertDialog.Builder usage in favor of MaterialAlertDialogBuilder. */
object AlertDialogRule : KtCallRule, JavaNewRule {
private val ALERT_DIALOG_FQNS = setOf("android.app.AlertDialog", "androidx.appcompat.app.AlertDialog")
private fun message(fqn: String) = "Using $fqn.Builder instead of MaterialAlertDialogBuilder"
override fun onCall(call: KtCallExpression, context: KotlinFileContext, reporter: Reporter) {
val callee = (call.calleeExpression as? KtNameReferenceExpression)?.getReferencedName() ?: return
if (callee != "Builder") {
return
}
val receiver = (call.getQualifiedExpressionForSelector() as? KtDotQualifiedExpression)?.receiverExpression?.text ?: return
if (!receiver.endsWith("AlertDialog")) {
return
}
val fqn = context.resolveFqn(receiver)
if (fqn in ALERT_DIALOG_FQNS) {
reporter.report("AlertDialogBuilderUsage", call, context.lineOf(call.textOffset), message(fqn))
}
}
override fun onNew(expression: PsiNewExpression, context: JavaFileContext, reporter: Reporter) {
val ref = expression.classReference?.text ?: return
if (!ref.endsWith("AlertDialog.Builder")) {
return
}
val fqn = context.resolveFqn(ref.removeSuffix(".Builder"))
if (fqn in ALERT_DIALOG_FQNS) {
reporter.report("AlertDialogBuilderUsage", expression, context.lineOf(expression.textOffset), message(fqn))
}
}
}

View File

@ -0,0 +1,14 @@
package org.signal.fastlint.rules
import org.signal.fastlint.Rule
/** Every rule the fast linter runs. Add a new rule here to register it. */
val ALL_RULES: List<Rule> = listOf(
LogNotSignalRule,
LogTagInlinedRule,
VersionCodeRule,
ForegroundServiceRule,
AlertDialogRule,
DatabaseReferenceRule,
StringResourceEscapingRule
)

View File

@ -0,0 +1,75 @@
package org.signal.fastlint.rules
import com.intellij.psi.PsiClass
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiField
import com.intellij.psi.util.PsiTreeUtil
import org.jetbrains.kotlin.psi.KtClassOrObject
import org.jetbrains.kotlin.psi.KtProperty
import org.signal.fastlint.JavaClassRule
import org.signal.fastlint.JavaFileContext
import org.signal.fastlint.KotlinFileContext
import org.signal.fastlint.KtClassRule
import org.signal.fastlint.Reporter
import java.util.Locale
/**
* A class extending "Database" with a String column whose name contains "recipient"/"thread" must
* implement Recipient/ThreadIdDatabaseReference. Type information is approximated syntactically.
*/
object DatabaseReferenceRule : KtClassRule, JavaClassRule {
override fun onClass(klass: KtClassOrObject, context: KotlinFileContext, reporter: Reporter) {
val fields = klass.body?.properties.orEmpty().filter { it.isStringTyped() }.mapNotNull { it.name }
check(klass, context.lineOf(klass.textOffset), klass.superTypeListEntries.map { it.text }, emptyList(), fields, reporter)
}
override fun onClass(klass: PsiClass, context: JavaFileContext, reporter: Reporter) {
// Read fields syntactically (direct children only) to avoid building a member cache, which would
// resolve superclasses and route into the (uninitialized) Kotlin resolver.
val fields = PsiTreeUtil.getChildrenOfTypeAsList(klass, PsiField::class.java)
.filter { it.typeElement?.text == "String" || it.typeElement?.text == "java.lang.String" }
.map { it.name }
check(
element = klass,
line = context.lineOf(klass.textOffset),
superTypes = klass.extendsList?.referenceElements?.map { it.text }.orEmpty(),
implementsTypes = klass.implementsList?.referenceElements?.map { it.text }.orEmpty(),
fieldNames = fields,
reporter = reporter
)
}
private fun check(
element: PsiElement,
line: Int,
superTypes: List<String>,
implementsTypes: List<String>,
fieldNames: List<String>,
reporter: Reporter
) {
val all = superTypes + implementsTypes
val extendsDatabase = all.any { it.substringBefore('(').substringAfterLast('.').trim() == "Database" }
if (!extendsDatabase) {
return
}
val implementsRecipient = all.any { it.contains("RecipientIdDatabaseReference") }
val implementsThread = all.any { it.contains("ThreadIdDatabaseReference") }
fieldNames.forEach { name ->
val lower = name.lowercase(Locale.US)
if (!implementsRecipient && lower.contains("recipient")) {
reporter.report("RecipientIdDatabaseReferenceUsage", element, line, "References a RecipientId ('$name') without implementing RecipientIdDatabaseReference")
}
if (!implementsThread && lower.contains("thread")) {
reporter.report("ThreadIdDatabaseReferenceUsage", element, line, "References a thread id ('$name') without implementing ThreadIdDatabaseReference")
}
}
}
private fun KtProperty.isStringTyped(): Boolean {
val typeText = typeReference?.text ?: return false
return typeText == "String" || typeText == "kotlin.String"
}
}

View File

@ -0,0 +1,48 @@
package org.signal.fastlint.rules
import com.intellij.psi.PsiMethodCallExpression
import org.jetbrains.kotlin.psi.KtCallExpression
import org.jetbrains.kotlin.psi.KtDotQualifiedExpression
import org.jetbrains.kotlin.psi.KtNameReferenceExpression
import org.jetbrains.kotlin.psi.psiUtil.getQualifiedExpressionForSelector
import org.signal.fastlint.JavaCallRule
import org.signal.fastlint.JavaFileContext
import org.signal.fastlint.KotlinFileContext
import org.signal.fastlint.KtCallRule
import org.signal.fastlint.Reporter
/** Flags Context/ContextCompat.startForegroundService outside ForegroundServiceUtil. */
object ForegroundServiceRule : KtCallRule, JavaCallRule {
private const val MESSAGE = "Using startForegroundService instead of ForegroundServiceUtil"
override fun onCall(call: KtCallExpression, context: KotlinFileContext, reporter: Reporter) {
val callee = (call.calleeExpression as? KtNameReferenceExpression)?.getReferencedName() ?: return
if (callee != "startForegroundService" || context.file.name == "ForegroundServiceUtil.kt") {
return
}
val receiver = (call.getQualifiedExpressionForSelector() as? KtDotQualifiedExpression)?.receiverExpression?.text
if (isLikelyContextReceiver(receiver)) {
reporter.report("StartForegroundServiceUsage", call, context.lineOf(call.textOffset), MESSAGE)
}
}
override fun onCall(call: PsiMethodCallExpression, context: JavaFileContext, reporter: Reporter) {
val callee = call.methodExpression.referenceName ?: return
if (callee != "startForegroundService" || context.file.name == "ForegroundServiceUtil.java") {
return
}
if (isLikelyContextReceiver(call.methodExpression.qualifierExpression?.text)) {
reporter.report("StartForegroundServiceUsage", call, context.lineOf(call.textOffset), MESSAGE)
}
}
/**
* The rule only targets the framework call (Context / ContextCompat). Without type resolution we
* approximate: a null/lowercase receiver is a context instance, "ContextCompat" is the helper; an
* upper-case qualifier is a class (a Signal wrapper like FcmFetchManager), so skip it.
*/
private fun isLikelyContextReceiver(receiver: String?): Boolean {
return receiver == null || receiver == "ContextCompat" || receiver.firstOrNull()?.isLowerCase() == true
}
}

View File

@ -0,0 +1,69 @@
package org.signal.fastlint.rules
import com.intellij.psi.PsiMethodCallExpression
import org.jetbrains.kotlin.psi.KtCallExpression
import org.jetbrains.kotlin.psi.KtDotQualifiedExpression
import org.jetbrains.kotlin.psi.KtNameReferenceExpression
import org.jetbrains.kotlin.psi.psiUtil.getQualifiedExpressionForSelector
import org.signal.fastlint.JavaCallRule
import org.signal.fastlint.JavaFileContext
import org.signal.fastlint.KotlinFileContext
import org.signal.fastlint.KtCallRule
import org.signal.fastlint.Reporter
/**
* Flags logging methods (v/d/i/w/e/wtf) called on any receiver other than the app's logger
* (org.signal.core.util.logging.Log). The single-character method names keep this targeted at
* logger-style calls.
*
* Allowed besides Log itself: chains off Log such as Log.internal(), the sensitive-data logger
* SensitiveLog, and any call within the logging package (which implements the logger).
*/
object LogNotSignalRule : KtCallRule, JavaCallRule {
private val LOG_METHODS = setOf("v", "d", "i", "w", "e", "wtf")
private const val SIGNAL_LOG = "org.signal.core.util.logging.Log"
private const val SENSITIVE_LOG = "org.signal.registration.util.SensitiveLog"
private const val LOGGING_PACKAGE = "org.signal.core.util.logging"
// Logger implementations delegate to a wrapped logger, so we do not flag them against themselves.
private val LOGGER_IMPLEMENTATION_FILES = setOf("SensitiveLog.kt", "SensitiveLog.java")
override fun onCall(call: KtCallExpression, context: KotlinFileContext, reporter: Reporter) {
val callee = (call.calleeExpression as? KtNameReferenceExpression)?.getReferencedName() ?: return
if (callee !in LOG_METHODS) {
return
}
if (isLoggerImplementation(context.file.name, context.ktFile.packageFqName.asString())) {
return
}
val receiver = (call.getQualifiedExpressionForSelector() as? KtDotQualifiedExpression)?.receiverExpression?.text ?: return
val fqn = context.resolveFqn(receiver)
if (!isAllowedLogger(fqn)) {
reporter.report("LogNotSignal", call, context.lineOf(call.textOffset), "Using '$fqn' instead of a Signal Logger")
}
}
override fun onCall(call: PsiMethodCallExpression, context: JavaFileContext, reporter: Reporter) {
val callee = call.methodExpression.referenceName ?: return
if (callee !in LOG_METHODS) {
return
}
if (isLoggerImplementation(context.file.name, context.javaFile.packageName)) {
return
}
val receiver = call.methodExpression.qualifierExpression?.text ?: return
val fqn = context.resolveFqn(receiver)
if (!isAllowedLogger(fqn)) {
reporter.report("LogNotSignal", call, context.lineOf(call.textOffset), "Using '$fqn' instead of a Signal Logger")
}
}
private fun isAllowedLogger(fqn: String): Boolean {
return fqn == SIGNAL_LOG || fqn.startsWith("$SIGNAL_LOG.") || fqn == SENSITIVE_LOG
}
private fun isLoggerImplementation(fileName: String, packageName: String): Boolean {
return packageName == LOGGING_PACKAGE || fileName in LOGGER_IMPLEMENTATION_FILES
}
}

View File

@ -0,0 +1,53 @@
package org.signal.fastlint.rules
import com.intellij.psi.PsiMethodCallExpression
import com.intellij.psi.PsiReferenceExpression
import org.jetbrains.kotlin.psi.KtCallExpression
import org.jetbrains.kotlin.psi.KtDotQualifiedExpression
import org.jetbrains.kotlin.psi.KtNameReferenceExpression
import org.jetbrains.kotlin.psi.psiUtil.getQualifiedExpressionForSelector
import org.signal.fastlint.JavaCallRule
import org.signal.fastlint.JavaFileContext
import org.signal.fastlint.KotlinFileContext
import org.signal.fastlint.KtCallRule
import org.signal.fastlint.Reporter
/**
* Flags calls to the app logger (org.signal.core.util.logging.Log) that pass an inline string tag
* instead of a tag constant.
*/
object LogTagInlinedRule : KtCallRule, JavaCallRule {
private val LOG_METHODS = setOf("v", "d", "i", "w", "e", "wtf")
private const val SIGNAL_LOG = "org.signal.core.util.logging.Log"
override fun onCall(call: KtCallExpression, context: KotlinFileContext, reporter: Reporter) {
val callee = (call.calleeExpression as? KtNameReferenceExpression)?.getReferencedName() ?: return
if (callee !in LOG_METHODS) {
return
}
val receiver = (call.getQualifiedExpressionForSelector() as? KtDotQualifiedExpression)?.receiverExpression?.text ?: return
if (context.resolveFqn(receiver) != SIGNAL_LOG) {
return
}
val firstArg = call.valueArguments.firstOrNull()?.getArgumentExpression()
if (firstArg != null && firstArg !is KtNameReferenceExpression && firstArg !is KtDotQualifiedExpression) {
reporter.report("LogTagInlined", call, context.lineOf(call.textOffset), "Not using a tag constant")
}
}
override fun onCall(call: PsiMethodCallExpression, context: JavaFileContext, reporter: Reporter) {
val callee = call.methodExpression.referenceName ?: return
if (callee !in LOG_METHODS) {
return
}
val receiver = call.methodExpression.qualifierExpression?.text ?: return
if (context.resolveFqn(receiver) != SIGNAL_LOG) {
return
}
val firstArg = call.argumentList.expressions.firstOrNull()
if (firstArg != null && firstArg !is PsiReferenceExpression) {
reporter.report("LogTagInlined", call, context.lineOf(call.textOffset), "Not using a tag constant")
}
}
}

View File

@ -0,0 +1,76 @@
package org.signal.fastlint.rules
import org.signal.fastlint.XmlFileContext
import org.signal.fastlint.XmlSink
import org.signal.fastlint.XmlStringResourceRule
/**
* Flags string resource values that aapt rejects:
* - an unescaped apostrophe outside a double-quoted span, or an unbalanced (odd) unescaped double
* quote a backslash escapes the next character, an unescaped double quote toggles a "quoting"
* span (in which apostrophes are allowed), and an apostrophe outside such a span must be \';
* - a value starting with '@' or '?' that is not a resource / theme-attribute reference, which aapt
* interprets as a (broken) reference unless escaped as \@ / \?.
*/
object StringResourceEscapingRule : XmlStringResourceRule {
// A resource reference: @[+][*][package:]type/name, e.g. @string/foo, @+id/foo, @android:id/foo.
private val RESOURCE_REFERENCE = Regex("^@\\+?\\*?(\\w+:)?\\w+/.+")
override fun onStringResource(name: String?, value: String, line: Int, context: XmlFileContext, sink: XmlSink) {
checkQuotes(value, line, sink)
checkLeadingReferenceCharacter(value, line, sink)
}
private fun checkQuotes(value: String, line: Int, sink: XmlSink) {
var inQuotedSpan = false
var unescapedDoubleQuotes = 0
var unescapedApostropheOutsideQuotes = false
var i = 0
while (i < value.length) {
when (value[i]) {
'\\' -> i++ // The next character is escaped; skip it.
'"' -> {
unescapedDoubleQuotes++
inQuotedSpan = !inQuotedSpan
}
'\'' -> if (!inQuotedSpan) {
unescapedApostropheOutsideQuotes = true
}
}
i++
}
if (unescapedApostropheOutsideQuotes) {
sink.report("StringResourceEscaping", line, "Apostrophe in a string resource must be escaped as \\' or the value wrapped in double quotes")
}
if (unescapedDoubleQuotes % 2 != 0) {
sink.report("StringResourceEscaping", line, "Unbalanced double quote in a string resource; escape a literal quote as \\\"")
}
}
private fun checkLeadingReferenceCharacter(value: String, line: Int, sink: XmlSink) {
val trimmed = value.trimStart()
if (trimmed.isEmpty()) {
return
}
when (trimmed[0]) {
'@' -> if (!isResourceReference(trimmed)) {
sink.report("StringResourceEscaping", line, "A string resource starting with '@' must be escaped as \\@ unless it is a resource reference")
}
'?' -> if (!isThemeReference(trimmed)) {
sink.report("StringResourceEscaping", line, "A string resource starting with '?' must be escaped as \\? unless it is a theme attribute reference")
}
}
}
private fun isResourceReference(value: String): Boolean {
return value == "@null" || value == "@empty" || RESOURCE_REFERENCE.containsMatchIn(value)
}
private fun isThemeReference(value: String): Boolean {
// ?attr/x, ?android:attr/x, or the ?colorPrimary shorthand: '?' followed by an identifier.
return value.length > 1 && (value[1].isLetter() || value[1] == '_')
}
}

View File

@ -0,0 +1,27 @@
package org.signal.fastlint.rules
import com.intellij.psi.PsiReferenceExpression
import org.jetbrains.kotlin.psi.KtSimpleNameExpression
import org.signal.fastlint.JavaFileContext
import org.signal.fastlint.JavaReferenceRule
import org.signal.fastlint.KotlinFileContext
import org.signal.fastlint.KtNameRule
import org.signal.fastlint.Reporter
/** Flags references to Build.VERSION_CODES.* constants; Signal convention is the numeric API level. */
object VersionCodeRule : KtNameRule, JavaReferenceRule {
private const val MESSAGE = "Using 'VERSION_CODES' reference instead of the numeric value"
override fun onName(name: KtSimpleNameExpression, context: KotlinFileContext, reporter: Reporter) {
if (name.getReferencedName() == "VERSION_CODES") {
reporter.report("VersionCodeUsage", name, context.lineOf(name.textOffset), MESSAGE)
}
}
override fun onReference(reference: PsiReferenceExpression, context: JavaFileContext, reporter: Reporter) {
if (reference.referenceName == "VERSION_CODES") {
reporter.report("VersionCodeUsage", reference, context.lineOf(reference.textOffset), MESSAGE)
}
}
}

View File

@ -0,0 +1,25 @@
package org.signal.fastlint
import org.signal.fastlint.rules.ALL_RULES
import java.io.File
/**
* Shared engine for rule tests. Created once per test JVM (PSI environment setup is expensive) and
* reused; tests run sequentially within a JVM so the single instance is safe.
*/
private val ENGINE = FastLint(ALL_RULES)
fun lintKotlin(code: String, fileName: String = "Test.kt"): List<Finding> =
ENGINE.analyzeKotlin(File("/repo/app/src/main/java/org/test/$fileName"), code)
fun lintJava(code: String, fileName: String = "Test.java"): List<Finding> =
ENGINE.analyzeJava(File("/repo/app/src/main/java/org/test/$fileName"), code)
fun lintLayout(xml: String): List<Finding> =
ENGINE.analyzeXml(File("/repo/app/src/main/res/layout/test.xml"), xml)
fun lintValues(xml: String): List<Finding> =
ENGINE.analyzeXml(File("/repo/app/src/main/res/values/strings.xml"), xml)
/** Sorted list of check ids in the findings, for concise assertions. */
fun List<Finding>.ids(): List<String> = map { it.checkId }.sorted()

View File

@ -0,0 +1,47 @@
package org.signal.fastlint.rules
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.signal.fastlint.ids
import org.signal.fastlint.lintJava
import org.signal.fastlint.lintKotlin
class AlertDialogRuleTest {
@Test
fun `kotlin appcompat AlertDialog Builder is flagged`() {
val findings = lintKotlin(
"""
import android.content.Context
import androidx.appcompat.app.AlertDialog
class A { fun f(c: Context) { AlertDialog.Builder(c) } }
""".trimIndent()
)
assertEquals(listOf("AlertDialogBuilderUsage"), findings.ids())
}
@Test
fun `java appcompat AlertDialog Builder is flagged`() {
val findings = lintJava(
"""
import android.content.Context;
import androidx.appcompat.app.AlertDialog;
class A { void f(Context c) { new AlertDialog.Builder(c); } }
""".trimIndent()
)
assertEquals(listOf("AlertDialogBuilderUsage"), findings.ids())
}
@Test
fun `MaterialAlertDialogBuilder is not flagged`() {
val findings = lintKotlin(
"""
import android.content.Context
import com.google.android.material.dialog.MaterialAlertDialogBuilder
class A { fun f(c: Context) { MaterialAlertDialogBuilder(c) } }
""".trimIndent()
)
assertTrue(findings.isEmpty())
}
}

View File

@ -0,0 +1,71 @@
package org.signal.fastlint.rules
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.signal.fastlint.ids
import org.signal.fastlint.lintJava
import org.signal.fastlint.lintKotlin
class DatabaseReferenceRuleTest {
@Test
fun `java database with recipient column is flagged`() {
val findings = lintJava(
"""
class MyTable extends Database {
private static final String RECIPIENT_ID = "recipient_id";
}
""".trimIndent()
)
assertEquals(listOf("RecipientIdDatabaseReferenceUsage"), findings.ids())
}
@Test
fun `java database with thread column is flagged`() {
val findings = lintJava(
"""
class MyTable extends Database {
private static final String THREAD_ID = "thread_id";
}
""".trimIndent()
)
assertEquals(listOf("ThreadIdDatabaseReferenceUsage"), findings.ids())
}
@Test
fun `database implementing the interface is not flagged`() {
val findings = lintJava(
"""
class MyTable extends Database implements RecipientIdDatabaseReference {
private static final String RECIPIENT_ID = "recipient_id";
}
""".trimIndent()
)
assertTrue(findings.isEmpty())
}
@Test
fun `non-database class is not flagged`() {
val findings = lintJava(
"""
class Foo {
private static final String RECIPIENT_ID = "recipient_id";
}
""".trimIndent()
)
assertTrue(findings.isEmpty())
}
@Test
fun `kotlin database with recipient column is flagged`() {
val findings = lintKotlin(
"""
class MyTable : Database() {
val recipientId: String = ""
}
""".trimIndent()
)
assertEquals(listOf("RecipientIdDatabaseReferenceUsage"), findings.ids())
}
}

View File

@ -0,0 +1,51 @@
package org.signal.fastlint.rules
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.signal.fastlint.ids
import org.signal.fastlint.lintJava
import org.signal.fastlint.lintKotlin
class ForegroundServiceRuleTest {
@Test
fun `context instance call is flagged`() {
val findings = lintKotlin(
"""
import android.content.Context
class A { fun f(context: Context) { context.startForegroundService(null) } }
""".trimIndent()
)
assertEquals(listOf("StartForegroundServiceUsage"), findings.ids())
}
@Test
fun `java ContextCompat call is flagged`() {
val findings = lintJava(
"""
import androidx.core.content.ContextCompat;
class A { void f(android.content.Context c, android.content.Intent i) { ContextCompat.startForegroundService(c, i); } }
""".trimIndent()
)
assertEquals(listOf("StartForegroundServiceUsage"), findings.ids())
}
@Test
fun `static wrapper call on a class is not flagged`() {
val findings = lintKotlin("""class A { fun f() { FcmFetchManager.startForegroundService(null) } }""")
assertTrue(findings.isEmpty())
}
@Test
fun `call inside ForegroundServiceUtil is not flagged`() {
val findings = lintKotlin(
"""
import android.content.Context
class A { fun f(context: Context) { context.startForegroundService(null) } }
""".trimIndent(),
fileName = "ForegroundServiceUtil.kt"
)
assertTrue(findings.isEmpty())
}
}

View File

@ -0,0 +1,138 @@
package org.signal.fastlint.rules
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.signal.fastlint.ids
import org.signal.fastlint.lintJava
import org.signal.fastlint.lintKotlin
class LogNotSignalRuleTest {
@Test
fun `kotlin android Log is flagged`() {
val findings = lintKotlin(
"""
import android.util.Log
class A { fun f() { Log.d("tag", "m") } }
""".trimIndent()
)
assertEquals(listOf("LogNotSignal"), findings.ids())
}
@Test
fun `java android Log is flagged`() {
val findings = lintJava(
"""
import android.util.Log;
class A { void f() { Log.d("tag", "m"); } }
""".trimIndent()
)
assertEquals(listOf("LogNotSignal"), findings.ids())
}
@Test
fun `libsignal server logger is flagged`() {
val findings = lintKotlin(
"""
import org.signal.libsignal.protocol.logging.Log
class A { fun f() { Log.w("tag", "m") } }
""".trimIndent()
)
assertEquals(listOf("LogNotSignal"), findings.ids())
}
@Test
fun `an unrecognized third-party logger is also flagged`() {
val findings = lintKotlin(
"""
import timber.log.Timber
class A { fun f() { Timber.e("m") } }
""".trimIndent()
)
assertEquals(listOf("LogNotSignal"), findings.ids())
}
@Test
fun `app logger is not flagged`() {
val findings = lintKotlin(
"""
import org.signal.core.util.logging.Log
class A {
val tag = "A"
fun f() { Log.d(tag, "m") }
}
""".trimIndent()
)
assertTrue(findings.isEmpty())
}
@Test
fun `a chain off the app logger such as Log internal is not flagged`() {
val findings = lintKotlin(
"""
import org.signal.core.util.logging.Log
class A {
val tag = "A"
fun f() { Log.internal().d(tag, "m") }
}
""".trimIndent()
)
assertFalse("LogNotSignal" in findings.ids())
}
@Test
fun `SensitiveLog is not flagged`() {
val findings = lintKotlin(
"""
import org.signal.registration.util.SensitiveLog
class A {
val tag = "A"
fun f() { SensitiveLog.d(tag, "m") }
}
""".trimIndent()
)
assertFalse("LogNotSignal" in findings.ids())
}
@Test
fun `calls within the logging package are not flagged`() {
val findings = lintKotlin(
"""
package org.signal.core.util.logging
class CompoundLogger {
fun f(logger: Any) { logger.d("t", "m") }
}
""".trimIndent()
)
assertTrue(findings.isEmpty())
}
@Test
fun `SensitiveLog's own delegating implementation is not flagged`() {
val findings = lintKotlin(
"""
package org.signal.registration.util
class SensitiveLog(private val logger: Any) {
fun d(tag: String, message: String) { this.logger.d(tag, message) }
}
""".trimIndent(),
fileName = "SensitiveLog.kt"
)
assertTrue(findings.isEmpty())
}
@Test
fun `SuppressLint silences the rule`() {
val findings = lintKotlin(
"""
import android.annotation.SuppressLint
import android.util.Log
@SuppressLint("LogNotSignal")
class A { fun f() { Log.d("tag", "m") } }
""".trimIndent()
)
assertTrue(findings.isEmpty())
}
}

View File

@ -0,0 +1,71 @@
package org.signal.fastlint.rules
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.signal.fastlint.ids
import org.signal.fastlint.lintJava
import org.signal.fastlint.lintKotlin
class LogTagInlinedRuleTest {
@Test
fun `app logger with inline string tag is flagged`() {
val findings = lintKotlin(
"""
import org.signal.core.util.logging.Log
class A { fun f() { Log.d("literal", "m") } }
""".trimIndent()
)
assertEquals(listOf("LogTagInlined"), findings.ids())
}
@Test
fun `java app logger with inline string tag is flagged`() {
val findings = lintJava(
"""
import org.signal.core.util.logging.Log;
class A { void f() { Log.d("literal", "m"); } }
""".trimIndent()
)
assertEquals(listOf("LogTagInlined"), findings.ids())
}
@Test
fun `app logger with constant tag is not flagged`() {
val findings = lintKotlin(
"""
import org.signal.core.util.logging.Log
class A {
val tag = "A"
fun f() { Log.d(tag, "m") }
}
""".trimIndent()
)
assertTrue(findings.isEmpty())
}
@Test
fun `non-signal logger with inline tag is not flagged as LogTagInlined`() {
val findings = lintKotlin(
"""
import android.util.Log
class A { fun f() { Log.d("literal", "m") } }
""".trimIndent()
)
assertEquals(listOf("LogNotSignal"), findings.ids())
}
@Test
fun `SuppressLint silences the rule`() {
val findings = lintKotlin(
"""
import android.annotation.SuppressLint
import org.signal.core.util.logging.Log
@SuppressLint("LogTagInlined")
class A { fun f() { Log.d("literal", "m") } }
""".trimIndent()
)
assertTrue(findings.isEmpty())
}
}

View File

@ -0,0 +1,90 @@
package org.signal.fastlint.rules
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.signal.fastlint.ids
import org.signal.fastlint.lintValues
class StringResourceEscapingRuleTest {
@Test
fun `unescaped apostrophe is flagged`() {
val findings = lintValues("""<resources><string name="x">Don't</string></resources>""")
assertEquals(listOf("StringResourceEscaping"), findings.ids())
}
@Test
fun `escaped apostrophe is not flagged`() {
val findings = lintValues("""<resources><string name="x">Don\'t</string></resources>""")
assertTrue(findings.isEmpty())
}
@Test
fun `apostrophe inside a double-quoted span is not flagged`() {
val findings = lintValues("""<resources><string name="x">"Don't"</string></resources>""")
assertTrue(findings.isEmpty())
}
@Test
fun `unbalanced double quote is flagged`() {
val findings = lintValues("""<resources><string name="x">5" tall</string></resources>""")
assertEquals(listOf("StringResourceEscaping"), findings.ids())
}
@Test
fun `escaped double quote is not flagged`() {
val findings = lintValues("""<resources><string name="x">5\" tall</string></resources>""")
assertTrue(findings.isEmpty())
}
@Test
fun `balanced double quotes are not flagged`() {
val findings = lintValues("""<resources><string name="x">"hello world"</string></resources>""")
assertTrue(findings.isEmpty())
}
@Test
fun `value starting with a literal at-sign is flagged`() {
val findings = lintValues("""<resources><string name="x">@everyone</string></resources>""")
assertEquals(listOf("StringResourceEscaping"), findings.ids())
}
@Test
fun `escaped leading at-sign is not flagged`() {
val findings = lintValues("""<resources><string name="x">\@everyone</string></resources>""")
assertTrue(findings.isEmpty())
}
@Test
fun `a resource reference value is not flagged`() {
val findings = lintValues("""<resources><string name="x">@string/foo</string></resources>""")
assertTrue(findings.isEmpty())
}
@Test
fun `at-null is not flagged`() {
val findings = lintValues("""<resources><string name="x">@null</string></resources>""")
assertTrue(findings.isEmpty())
}
@Test
fun `value starting with a literal question mark is flagged`() {
val findings = lintValues("""<resources><string name="x">???</string></resources>""")
assertEquals(listOf("StringResourceEscaping"), findings.ids())
}
@Test
fun `a theme attribute reference is not flagged`() {
val findings = lintValues("""<resources><string name="x">?attr/colorPrimary</string></resources>""")
assertTrue(findings.isEmpty())
}
@Test
fun `tools ignore suppresses the rule`() {
val findings = lintValues(
"""<resources xmlns:tools="http://schemas.android.com/tools"><string name="x" tools:ignore="StringResourceEscaping">Don't</string></resources>"""
)
assertTrue(findings.isEmpty())
}
}

View File

@ -0,0 +1,39 @@
package org.signal.fastlint.rules
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.signal.fastlint.ids
import org.signal.fastlint.lintJava
import org.signal.fastlint.lintKotlin
class VersionCodeRuleTest {
@Test
fun `kotlin VERSION_CODES reference is flagged`() {
val findings = lintKotlin(
"""
import android.os.Build
class A { fun f() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O }
""".trimIndent()
)
assertEquals(listOf("VersionCodeUsage"), findings.ids())
}
@Test
fun `java VERSION_CODES reference is flagged`() {
val findings = lintJava(
"""
import android.os.Build;
class A { boolean f() { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O; } }
""".trimIndent()
)
assertEquals(listOf("VersionCodeUsage"), findings.ids())
}
@Test
fun `VERSION_CODES inside a string literal is not flagged`() {
val findings = lintKotlin("""class A { val s = "see VERSION_CODES.O for details" }""")
assertTrue(findings.isEmpty())
}
}

View File

@ -8,3 +8,8 @@ lint = "32.1.1"
lint-api = { module = "com.android.tools.lint:lint-api", version.ref = "lint" }
lint-checks = { module = "com.android.tools.lint:lint-checks", version.ref = "lint" }
lint-tests = { module = "com.android.tools.lint:lint-tests", version.ref = "lint" }
# Parser front-ends used by the fast custom linter (:fast-lint). Same release as lint, so they
# share the "lint" version and are already covered by dependency verification.
intellij-core = { module = "com.android.tools.external.com-intellij:intellij-core", version.ref = "lint" }
kotlin-compiler = { module = "com.android.tools.external.com-intellij:kotlin-compiler", version.ref = "lint" }

View File

@ -145,6 +145,7 @@ include(":demo:apng")
// Testing/Lint modules
include(":lintchecks")
include(":fast-lint")
include(":benchmark")
include(":baseline-profile")
include(":microbenchmark")