From 077359cb11bcfc47ab0ea0ea467a9e34decddb1d Mon Sep 17 00:00:00 2001 From: sozinov Date: Mon, 25 May 2026 11:15:44 +0300 Subject: [PATCH 1/5] MOBILE-178: Add custom detekt rules for Gson @SerializedName enforcement --- .github/workflows/lint_unitTests_build.yml | 3 + detekt-rules/build.gradle | 27 + .../mindbox/detekt/GsonSerializedNameRule.kt | 263 +++++++++ .../mindbox/detekt/MindboxRuleSetProvider.kt | 25 + .../detekt/ProjectGsonClassNameProvider.kt | 207 +++++++ .../detekt/UnmonitoredGsonWrapperRule.kt | 61 ++ ...tlab.arturbosch.detekt.api.RuleSetProvider | 1 + .../detekt/GsonSerializedNameRuleTest.kt | 519 ++++++++++++++++++ .../detekt/UnmonitoredGsonWrapperRuleTest.kt | 168 ++++++ detekt.yml | 37 ++ gradle/libs.versions.toml | 7 +- modulesCommon.gradle | 11 + .../managers/MindboxEventManager.kt | 1 + .../models/operation/CustomFields.kt | 5 + .../mindbox/mobile_sdk/pushes/PushToken.kt | 2 + settings.gradle | 1 + 16 files changed, 1337 insertions(+), 1 deletion(-) create mode 100644 detekt-rules/build.gradle create mode 100644 detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt create mode 100644 detekt-rules/src/main/kotlin/cloud/mindbox/detekt/MindboxRuleSetProvider.kt create mode 100644 detekt-rules/src/main/kotlin/cloud/mindbox/detekt/ProjectGsonClassNameProvider.kt create mode 100644 detekt-rules/src/main/kotlin/cloud/mindbox/detekt/UnmonitoredGsonWrapperRule.kt create mode 100644 detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider create mode 100644 detekt-rules/src/test/kotlin/cloud/mindbox/detekt/GsonSerializedNameRuleTest.kt create mode 100644 detekt-rules/src/test/kotlin/cloud/mindbox/detekt/UnmonitoredGsonWrapperRuleTest.kt create mode 100644 detekt.yml diff --git a/.github/workflows/lint_unitTests_build.yml b/.github/workflows/lint_unitTests_build.yml index 73064f50d..080eee998 100644 --- a/.github/workflows/lint_unitTests_build.yml +++ b/.github/workflows/lint_unitTests_build.yml @@ -50,6 +50,9 @@ jobs: - name: lint check run: ./gradlew --no-daemon lintDebug + - name: detekt check + run: ./gradlew --no-daemon detekt + unit: runs-on: ubuntu-latest steps: diff --git a/detekt-rules/build.gradle b/detekt-rules/build.gradle new file mode 100644 index 000000000..9bd9ef6bc --- /dev/null +++ b/detekt-rules/build.gradle @@ -0,0 +1,27 @@ +apply plugin: 'java-library' +apply plugin: 'kotlin' + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +compileKotlin { + kotlinOptions { + jvmTarget = '11' + } +} + +compileTestKotlin { + kotlinOptions { + jvmTarget = '11' + } +} + +dependencies { + implementation libs.detekt.api + implementation libs.kotlin.stdlib + + testImplementation libs.detekt.test + testImplementation libs.junit +} diff --git a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt new file mode 100644 index 000000000..10f9259d5 --- /dev/null +++ b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt @@ -0,0 +1,263 @@ +package cloud.mindbox.detekt + +import io.gitlab.arturbosch.detekt.api.CodeSmell +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.api.Debt +import io.gitlab.arturbosch.detekt.api.Entity +import io.gitlab.arturbosch.detekt.api.Issue +import io.gitlab.arturbosch.detekt.api.Rule +import io.gitlab.arturbosch.detekt.api.Severity +import org.jetbrains.kotlin.descriptors.ClassDescriptor +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtClassLiteralExpression +import org.jetbrains.kotlin.psi.KtDotQualifiedExpression +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtObjectDeclaration +import org.jetbrains.kotlin.psi.KtParameter +import org.jetbrains.kotlin.psi.KtTypeAlias +import org.jetbrains.kotlin.psi.KtTypeReference +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType +import org.jetbrains.kotlin.resolve.BindingContext +import org.jetbrains.kotlin.resolve.DescriptorToSourceUtils +import org.jetbrains.kotlin.resolve.calls.util.getType +import org.jetbrains.kotlin.types.KotlinType + +class GsonSerializedNameRule( + config: Config, + private val projectGsonClassNameProvider: ProjectGsonClassNameProvider = ProjectGsonClassNameProvider() +) : Rule(config) { + + override val issue: Issue = Issue( + id = "GsonMissingSerializedName", + severity = Severity.Defect, + description = "Gson-serialized Kotlin data class constructor properties must declare @SerializedName.", + debt = Debt.FIVE_MINS + ) + + private val reportedParameterKeys: MutableSet = mutableSetOf() + private val checkedClasses: MutableSet = mutableSetOf() + + override fun preVisit(root: KtFile) { + reportedParameterKeys.clear() + } + + override fun visitKtFile(file: KtFile) { + super.visitKtFile(file) + checkGsonTypeReferences(file) + } + + override fun visitClass(klass: KtClass) { + super.visitClass(klass) + if (!klass.isData()) return + val className = klass.name ?: return + if (!klass.hasSerializedNameContract() && className !in findProjectGsonClassNames(klass.containingKtFile)) return + reportMissingSerializedNameParameters(klass) + } + + override fun visitCallExpression(expression: KtCallExpression) { + super.visitCallExpression(expression) + if (expression.calleeExpression?.text !in MONITORED_FUNCTION_NAMES) return + checkFirstArgumentType(expression) + checkTypeArguments(expression) + checkClassLiteralArguments(expression) + } + + override fun visitObjectDeclaration(declaration: KtObjectDeclaration) { + super.visitObjectDeclaration(declaration) + declaration.superTypeListEntries + .filter { superTypeEntry -> superTypeEntry.text.contains(TYPE_TOKEN) } + .flatMap { superTypeEntry -> extractModelNamesFromTypeText(superTypeEntry.text, declaration.containingKtFile) } + .forEach { className -> findClassByName(declaration.containingKtFile, className)?.let(::reportMissingSerializedNameParameters) } + } + + private fun checkFirstArgumentType(expression: KtCallExpression) { + val argument = expression.valueArguments.firstOrNull()?.getArgumentExpression() ?: return + checkKotlinType(type = argument.getType(bindingContext), source = expression) + } + + private fun checkTypeArguments(expression: KtCallExpression) { + expression.typeArguments + .mapNotNull { typeProjection -> typeProjection.typeReference } + .forEach { typeReference -> checkTypeReference(typeReference, expression) } + } + + private fun checkClassLiteralArguments(expression: KtCallExpression) { + expression.valueArguments + .mapNotNull { argument -> argument.getArgumentExpression() } + .forEach { argument -> + val classLiteral = when (argument) { + is KtDotQualifiedExpression -> argument.receiverExpression as? KtClassLiteralExpression + is KtClassLiteralExpression -> argument + else -> null + } + classLiteral?.let { literal -> checkKotlinType(type = literal.getType(bindingContext), source = expression) } + } + } + + private fun checkTypeReference(typeReference: KtTypeReference, source: KtCallExpression) { + checkKotlinType(type = bindingContext[BindingContext.TYPE, typeReference], source = source) + } + + private fun checkGsonTypeReferences(file: KtFile) { + val aliases = file.extractTypeAliases() + val dataClasses = file.collectDescendantsOfType() + .filter { klass -> klass.isData() } + .associateBy { klass -> klass.name.orEmpty() } + val referencedTypeTexts = ( + TYPE_TOKEN_PATTERN.findAll(file.text).map { match -> match.groupValues[1] } + + GSON_GENERIC_CALL_PATTERN.findAll(file.text).map { match -> match.groupValues[1] } + + GSON_CLASS_LITERAL_PATTERN.findAll(file.text).map { match -> match.groupValues[1] } + ).toList() + referencedTypeTexts + .flatMap { typeText -> extractModelNamesFromTypeText(typeText, aliases) } + .mapNotNull { className -> dataClasses[className] } + .forEach(::reportMissingSerializedNameParameters) + } + + private fun findProjectGsonClassNames(file: KtFile): Set { + return projectGsonClassNameProvider.findProjectGsonClassNames(file) + } + + private fun checkKotlinType(type: KotlinType?, source: KtCallExpression) { + type ?: return + val descriptor = type.constructor.declarationDescriptor as? ClassDescriptor + val sourceClass = descriptor?.let { DescriptorToSourceUtils.descriptorToDeclaration(it) } as? KtClass + sourceClass?.let(::reportMissingSerializedNameParameters) + type.arguments + .filterNot { projection -> projection.isStarProjection } + .forEach { projection -> checkKotlinType(type = projection.type, source = source) } + } + + private fun reportMissingSerializedNameParameters(klass: KtClass) { + if (!klass.isData()) return + val qualifiedName = klass.fqName?.asString() ?: return + if (!checkedClasses.add(qualifiedName)) return + klass.primaryConstructorParameters + .filter { parameter -> parameter.valOrVarKeyword != null } + .forEach { parameter -> + if (!parameter.hasSerializedNameAnnotation()) reportParameter(parameter, klass) + val typeRef = parameter.typeReference ?: return@forEach + val type = bindingContext[BindingContext.TYPE, typeRef] ?: return@forEach + checkKotlinTypeTransitively(type) + } + } + + private fun checkKotlinTypeTransitively(type: KotlinType) { + val descriptor = type.constructor.declarationDescriptor as? ClassDescriptor ?: return + val sourceClass = DescriptorToSourceUtils.descriptorToDeclaration(descriptor) as? KtClass + sourceClass?.let(::reportMissingSerializedNameParameters) + type.arguments + .filterNot { projection -> projection.isStarProjection } + .forEach { projection -> checkKotlinTypeTransitively(projection.type) } + } + + private fun reportParameter(parameter: KtParameter, klass: KtClass) { + val key = "${parameter.containingFile.name}:${parameter.textOffset}" + if (!reportedParameterKeys.add(key)) return + report( + CodeSmell( + issue = issue, + entity = Entity.from(parameter), + message = "${klass.name}.${parameter.name} must declare @SerializedName." + ) + ) + } + + private fun KtClass.hasSerializedNameContract(): Boolean { + return primaryConstructorParameters.any { parameter -> parameter.hasSerializedNameAnnotation() } + } + + private fun KtParameter.hasSerializedNameAnnotation(): Boolean { + return annotationEntries.any { annotationEntry -> + annotationEntry.shortName?.asString() == SERIALIZED_NAME + } + } + + private fun KtFile.extractTypeAliases(): Map { + val psiAliases = declarations + .filterIsInstance() + .associate { alias -> alias.name.orEmpty() to alias.getTypeReference()?.text.orEmpty() } + val textAliases = TYPE_ALIAS_PATTERN.findAll(text) + .associate { match -> match.groupValues[1] to match.groupValues[2].trim() } + return psiAliases + textAliases + } + + private fun extractModelNamesFromTypeText(typeText: String, file: KtFile): Set { + val aliases = file.extractTypeAliases() + return extractModelNamesFromTypeText(typeText, aliases) + } + + private fun extractModelNamesFromTypeText(typeText: String, aliases: Map): Set { + val normalizedText = aliases.entries + .sortedByDescending { alias -> alias.key.length } + .fold(typeText) { currentText, alias -> + currentText.replace(Regex("\\b${Regex.escape(alias.key)}\\b"), alias.value) + } + return MODEL_NAME_PATTERN + .findAll(normalizedText) + .map { match -> match.value.substringAfterLast('.') } + .filter { className -> className !in IGNORED_TYPE_NAMES } + .filter { className -> className.firstOrNull()?.isUpperCase() == true } + .toSet() + } + + private fun findClassByName(file: KtFile, className: String): KtClass? { + return file.collectDescendantsOfType() + .firstOrNull { klass -> klass.name == className } + } + + internal companion object { + private const val SERIALIZED_NAME = "SerializedName" + private const val TYPE_TOKEN = "TypeToken" + + // To add a new Gson wrapper: just add its name here. + // UnmonitoredGsonWrapperRule references this same set to stay in sync. + internal val MONITORED_FUNCTION_NAMES: Set = setOf( + "convertJsonToBody", + "fromJson", + "fromJsonTyped", + "operationBodyJson", + "toJson", + "toJsonTyped", + ) + + private val GSON_CLASS_LITERAL_PATTERN: Regex = Regex( + "\\b(?:fromJson|convertJsonToBody)\\s*\\([^\\n)]*?([A-Z][A-Za-z0-9_]*)::class\\.java" + ) + private val GSON_GENERIC_CALL_PATTERN: Regex = Regex( + "\\b(?:fromJson|fromJsonTyped|toJsonTyped|operationBodyJson)\\s*<\\s*([^>]+)>" + ) + private val MODEL_NAME_PATTERN: Regex = Regex("[A-Za-z_][A-Za-z0-9_.]*") + private val TYPE_ALIAS_PATTERN: Regex = Regex("typealias\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*([^\\n]+)") + private val TYPE_TOKEN_PATTERN: Regex = Regex("TypeToken\\s*<\\s*([^>]+)>") + + private val IGNORED_TYPE_NAMES: Set = setOf( + "Any", + "Array", + "Boolean", + "Byte", + "Char", + "Collection", + "Double", + "Float", + "HashMap", + "HashSet", + "Int", + "Iterable", + "List", + "Long", + "Map", + "MutableList", + "MutableMap", + "MutableSet", + "Number", + "Pair", + "Set", + "Short", + "String", + "TypeToken", + "Unit", + ) + } +} diff --git a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/MindboxRuleSetProvider.kt b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/MindboxRuleSetProvider.kt new file mode 100644 index 000000000..dd37c7645 --- /dev/null +++ b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/MindboxRuleSetProvider.kt @@ -0,0 +1,25 @@ +package cloud.mindbox.detekt + +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.api.RuleSet +import io.gitlab.arturbosch.detekt.api.RuleSetProvider + +class MindboxRuleSetProvider : RuleSetProvider { + + override val ruleSetId: String = "mindbox" + + private val projectGsonClassNameProvider: ProjectGsonClassNameProvider = ProjectGsonClassNameProvider() + + override fun instance(config: Config): RuleSet { + return RuleSet( + id = ruleSetId, + rules = listOf( + GsonSerializedNameRule( + config = config, + projectGsonClassNameProvider = projectGsonClassNameProvider + ), + UnmonitoredGsonWrapperRule(config = config), + ) + ) + } +} diff --git a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/ProjectGsonClassNameProvider.kt b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/ProjectGsonClassNameProvider.kt new file mode 100644 index 000000000..e791d8271 --- /dev/null +++ b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/ProjectGsonClassNameProvider.kt @@ -0,0 +1,207 @@ +package cloud.mindbox.detekt + +import org.jetbrains.kotlin.psi.KtFile +import java.io.File + +class ProjectGsonClassNameProvider { + + private val gsonClassNamesBySourceRoot: MutableMap> = mutableMapOf() + + fun findProjectGsonClassNames(file: KtFile): Set { + val sourceRoot: File = file.findSourceRoot() ?: return emptySet() + return gsonClassNamesBySourceRoot.getOrPut(sourceRoot.absolutePath) { + sourceRoot.walkTopDown() + .filter { sourceFile: File -> sourceFile.isFile && sourceFile.extension == KOTLIN_EXTENSION } + .map { sourceFile: File -> sourceFile.readText() } + .let(::extractProjectGsonClassNames) + } + } + + internal fun extractProjectGsonClassNames(fileTexts: Sequence): Set { + val texts: List = fileTexts.toList() + val aliases: Map = texts + .flatMap { text: String -> + TYPE_ALIAS_PATTERN.findAll(text).map { match: MatchResult -> + match.groupValues[1] to match.groupValues[2].trim() + } + } + .toMap() + val directNames: MutableSet = texts + .flatMap { text: String -> extractSerializedTypeTexts(text) } + .flatMap { typeText: String -> extractModelNamesFromTypeText(typeText, aliases) } + .toMutableSet() + val fieldTypeMap: Map> = buildDataClassFieldTypeMap(texts, aliases) + var changed = true + while (changed) { + changed = false + for (name in directNames.toSet()) { + fieldTypeMap[name]?.forEach { fieldType: String -> + if (directNames.add(fieldType)) changed = true + } + } + } + return directNames + } + + private fun extractSerializedTypeTexts(text: String): List { + val explicitlySerializedTypes: Sequence = + TYPE_TOKEN_PATTERN.findAll(text).map { match: MatchResult -> match.groupValues[1] } + + GSON_GENERIC_CALL_PATTERN.findAll(text).map { match: MatchResult -> match.groupValues[1] } + + GSON_CLASS_LITERAL_PATTERN.findAll(text).map { match: MatchResult -> match.groupValues[1] } + + TO_JSON_CONSTRUCTOR_PATTERN.findAll(text).map { match: MatchResult -> match.groupValues[1] } + + extractToJsonThisClassNames(text).asSequence() + val variableTypes: Map = extractTypedVariableNames(text) + val toJsonVariableTypes: Sequence = TO_JSON_VARIABLE_PATTERN.findAll(text) + .mapNotNull { match: MatchResult -> variableTypes[match.groupValues[1]] } + return (explicitlySerializedTypes + toJsonVariableTypes).toList() + } + + private fun extractToJsonThisClassNames(text: String): List { + return TO_JSON_THIS_PATTERN.findAll(text).mapNotNull { match: MatchResult -> + val textBefore: String = text.substring(0, match.range.first) + PRECEDING_CLASS_NAME_PATTERN.findAll(textBefore).lastOrNull()?.groupValues?.get(1) + }.toList() + } + + private fun buildDataClassFieldTypeMap( + texts: List, + aliases: Map, + ): Map> { + val result: MutableMap> = mutableMapOf() + for (text in texts) { + var searchPos = 0 + while (true) { + val headerMatch: MatchResult = DATA_CLASS_HEADER_PATTERN.find(text, searchPos) ?: break + val className: String = headerMatch.groupValues[1] + val afterHeader: Int = headerMatch.range.last + 1 + val parenStart: Int = text.indexOf('(', afterHeader) + if (parenStart < 0 || parenStart - afterHeader > 100) { + searchPos = afterHeader + continue + } + val constructorBody: String? = extractMatchingParenContent(text, parenStart) + if (constructorBody != null) { + CONSTRUCTOR_FIELD_LINE_PATTERN.findAll(constructorBody) + .flatMap { match: MatchResult -> + extractModelNamesFromTypeText(match.groupValues[1], aliases) + } + .forEach { fieldType: String -> + result.getOrPut(className) { mutableSetOf() }.add(fieldType) + } + } + searchPos = afterHeader + } + } + return result + } + + private fun extractMatchingParenContent(text: String, openParenPos: Int): String? { + var depth = 1 + var i = openParenPos + 1 + while (i < text.length && depth > 0) { + when (text[i]) { + '(' -> depth++ + ')' -> depth-- + } + i++ + } + return if (depth == 0) text.substring(openParenPos + 1, i - 1) else null + } + + private fun extractTypedVariableNames(text: String): Map { + val propertyTypes: Sequence> = VARIABLE_DECLARATION_TYPE_PATTERN.findAll(text) + .map { match: MatchResult -> match.groupValues[1] to match.groupValues[2] } + val functionParameterTypes: Sequence> = FUNCTION_PARAMETERS_PATTERN.findAll(text) + .flatMap { match: MatchResult -> + PARAMETER_DECLARATION_TYPE_PATTERN.findAll(match.groupValues[1]) + .map { parameterMatch: MatchResult -> + parameterMatch.groupValues[1] to parameterMatch.groupValues[2] + } + } + return (propertyTypes + functionParameterTypes).toMap() + } + + private fun extractModelNamesFromTypeText(typeText: String, aliases: Map): Set { + val normalizedText: String = aliases.entries + .sortedByDescending { alias: Map.Entry -> alias.key.length } + .fold(typeText) { currentText: String, alias: Map.Entry -> + currentText.replace(Regex("\\b${Regex.escape(alias.key)}\\b"), alias.value) + } + return MODEL_NAME_PATTERN + .findAll(normalizedText) + .map { match: MatchResult -> match.value.substringAfterLast('.') } + .filter { className: String -> className !in IGNORED_TYPE_NAMES } + .filter { className: String -> className.firstOrNull()?.isUpperCase() == true } + .toSet() + } + + private fun KtFile.findSourceRoot(): File? { + val path: String = virtualFile?.path ?: return null + val sourceRootPath: String = SOURCE_ROOT_MARKERS + .firstNotNullOfOrNull { marker: String -> + path.substringBefore(marker, missingDelimiterValue = "") + .takeIf { rootPrefix: String -> rootPrefix.isNotEmpty() } + ?.let { rootPrefix: String -> rootPrefix + MAIN_SOURCE_SET } + } ?: return null + return File(sourceRootPath) + } + + private companion object { + private const val KOTLIN_EXTENSION = "kt" + private const val MAIN_SOURCE_SET = "/src/main" + + private val GSON_CLASS_LITERAL_PATTERN: Regex = Regex( + "\\b(?:fromJson|convertJsonToBody)\\s*\\([^\\n)]*?([A-Z][A-Za-z0-9_]*)::class\\.java" + ) + private val GSON_GENERIC_CALL_PATTERN: Regex = Regex( + "\\b(?:fromJson|fromJsonTyped|toJsonTyped|operationBodyJson)\\s*<\\s*([^>]+)>" + ) + private val MODEL_NAME_PATTERN: Regex = Regex("[A-Za-z_][A-Za-z0-9_.]*") + private val SOURCE_ROOT_MARKERS: List = listOf("/src/main/java/", "/src/main/kotlin/") + private val TO_JSON_CONSTRUCTOR_PATTERN: Regex = Regex("\\btoJson\\s*\\(\\s*([A-Z][A-Za-z0-9_]*)\\s*\\(") + private val TO_JSON_VARIABLE_PATTERN: Regex = Regex("\\btoJson\\s*\\(\\s*([a-zA-Z_][A-Za-z0-9_]*)\\s*(?:,|\\))") + private val TYPE_ALIAS_PATTERN: Regex = Regex("typealias\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*([^\\n]+)") + private val TYPE_TOKEN_PATTERN: Regex = Regex("TypeToken\\s*<\\s*([^>]+)>") + private val FUNCTION_PARAMETERS_PATTERN: Regex = Regex("\\bfun\\s+[A-Za-z_][A-Za-z0-9_]*\\s*\\(([^)]*)\\)") + private val PARAMETER_DECLARATION_TYPE_PATTERN: Regex = Regex( + "(?:^|,)\\s*([a-zA-Z_][A-Za-z0-9_]*)\\s*:\\s*([A-Z][A-Za-z0-9_.]*(?:<[^>\\n]+>)?)" + ) + private val VARIABLE_DECLARATION_TYPE_PATTERN: Regex = Regex( + "\\b(?:val|var)\\s+([a-zA-Z_][A-Za-z0-9_]*)\\s*:\\s*([A-Z][A-Za-z0-9_.]*(?:<[^>\\n]+>)?)" + ) + private val DATA_CLASS_HEADER_PATTERN: Regex = Regex("data\\s+class\\s+([A-Za-z_][A-Za-z0-9_]*)") + private val CONSTRUCTOR_FIELD_LINE_PATTERN: Regex = Regex( + "(?:val|var)\\s+[a-zA-Z_][A-Za-z0-9_]*\\s*:\\s*([^\\n=]+)" + ) + private val TO_JSON_THIS_PATTERN: Regex = Regex("\\btoJson\\s*\\(\\s*this\\s*[,)]") + private val PRECEDING_CLASS_NAME_PATTERN: Regex = Regex("\\bclass\\s+([A-Z][A-Za-z0-9_]*)") + + private val IGNORED_TYPE_NAMES: Set = setOf( + "Any", + "Array", + "Boolean", + "Byte", + "Char", + "Collection", + "Double", + "Float", + "HashMap", + "HashSet", + "Int", + "Iterable", + "List", + "Long", + "Map", + "MutableList", + "MutableMap", + "MutableSet", + "Number", + "Pair", + "Set", + "Short", + "String", + "TypeToken", + "Unit", + ) + } +} diff --git a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/UnmonitoredGsonWrapperRule.kt b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/UnmonitoredGsonWrapperRule.kt new file mode 100644 index 000000000..032b2d538 --- /dev/null +++ b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/UnmonitoredGsonWrapperRule.kt @@ -0,0 +1,61 @@ +package cloud.mindbox.detekt + +import io.gitlab.arturbosch.detekt.api.CodeSmell +import io.gitlab.arturbosch.detekt.api.Config +import io.gitlab.arturbosch.detekt.api.Debt +import io.gitlab.arturbosch.detekt.api.Entity +import io.gitlab.arturbosch.detekt.api.Issue +import io.gitlab.arturbosch.detekt.api.Rule +import io.gitlab.arturbosch.detekt.api.Severity +import org.jetbrains.kotlin.lexer.KtTokens +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType + +class UnmonitoredGsonWrapperRule(config: Config) : Rule(config) { + + override val issue: Issue = Issue( + id = "UnmonitoredGsonWrapper", + severity = Severity.Warning, + description = "Function wraps a Gson call but is not monitored by GsonMissingSerializedName. " + + "Add its name to the monitored list in GsonSerializedNameRule.", + debt = Debt.FIVE_MINS + ) + + override fun visitNamedFunction(function: KtNamedFunction) { + super.visitNamedFunction(function) + val name = function.name ?: return + if (function.hasModifier(KtTokens.PRIVATE_KEYWORD)) return + if (function.hasModifier(KtTokens.OVERRIDE_KEYWORD)) return + if (function.typeParameters.isEmpty()) return + if (name in MONITORED_GSON_FUNCTION_NAMES) return + if (!function.containsDirectGsonCall()) return + report( + CodeSmell( + issue = issue, + entity = Entity.atName(function), + message = "'$name' calls Gson internally but is not monitored by GsonMissingSerializedName. " + + "Add '$name' to GsonSerializedNameRule.MONITORED_FUNCTION_NAMES " + + "(detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt) " + + "so that types passed to '$name' are checked for @SerializedName." + ) + ) + } + + private fun KtNamedFunction.containsDirectGsonCall(): Boolean { + return collectDescendantsOfType() + .any { call -> call.calleeExpression?.text in GSON_BASE_FUNCTION_NAMES } + } + + private companion object { + private val MONITORED_GSON_FUNCTION_NAMES: Set + get() = GsonSerializedNameRule.MONITORED_FUNCTION_NAMES + + private val GSON_BASE_FUNCTION_NAMES: Set = setOf( + "fromJson", + "toJson", + "toJsonTree", + "fromJsonTree", + ) + } +} diff --git a/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider b/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider new file mode 100644 index 000000000..9f5c9241b --- /dev/null +++ b/detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider @@ -0,0 +1 @@ +cloud.mindbox.detekt.MindboxRuleSetProvider diff --git a/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/GsonSerializedNameRuleTest.kt b/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/GsonSerializedNameRuleTest.kt new file mode 100644 index 000000000..350c9aa51 --- /dev/null +++ b/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/GsonSerializedNameRuleTest.kt @@ -0,0 +1,519 @@ +package cloud.mindbox.detekt + +import io.gitlab.arturbosch.detekt.test.TestConfig +import io.gitlab.arturbosch.detekt.test.lint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class GsonSerializedNameRuleTest { + + @get:Rule + val temporaryFolder: TemporaryFolder = TemporaryFolder() + + @Test + fun `reports data class used by Gson class literal in another file`(): Unit { + val modelFile: File = writeSourceFile( + relativePath = "com/example/GeoTargeting.kt", + content = """ + package com.example + + data class GeoTargeting( + val cityId: String, + val regionId: String, + val countryId: String, + ) + """.trimIndent() + ) + writeSourceFile( + relativePath = "com/example/GeoSerializationManager.kt", + content = """ + package com.example + + import com.google.gson.Gson + + class GeoSerializationManager(private val gson: Gson) { + fun deserialize(json: String): GeoTargeting { + return gson.fromJson(json, GeoTargeting::class.java) + } + } + """.trimIndent() + ) + + val messages: List = lintFile(modelFile) + assertEquals( + listOf( + "GeoTargeting.cityId must declare @SerializedName.", + "GeoTargeting.regionId must declare @SerializedName.", + "GeoTargeting.countryId must declare @SerializedName." + ), + messages + ) + } + + @Test + fun `reports data class used by TypeToken typealias`(): Unit { + val sourceFile: File = writeSourceFile( + relativePath = "com/example/PushToken.kt", + content = """ + package com.example + + import com.google.gson.Gson + import com.google.gson.reflect.TypeToken + + data class PrefPushToken( + val token: String, + val updateDate: Long, + ) + + typealias PushTokenMap = Map + typealias PrefPushTokenMap = Map + + fun decode(json: String): PrefPushTokenMap { + val type = object : TypeToken() {}.type + return Gson().fromJson(json, type) + } + """.trimIndent() + ) + + val messages: List = lintFile(sourceFile) + assertEquals( + listOf( + "PrefPushToken.token must declare @SerializedName.", + "PrefPushToken.updateDate must declare @SerializedName." + ), + messages + ) + } + + @Test + fun `reports data class used by typed toJson variable`(): Unit { + val modelFile: File = writeSourceFile( + relativePath = "com/example/GeoTargeting.kt", + content = """ + package com.example + + data class GeoTargeting( + val cityId: String, + ) + """.trimIndent() + ) + writeSourceFile( + relativePath = "com/example/GeoSerializationManager.kt", + content = """ + package com.example + + import com.google.gson.Gson + + class GeoSerializationManager(private val gson: Gson) { + fun serialize(inAppGeo: GeoTargeting): String { + return gson.toJson(inAppGeo) + } + } + """.trimIndent() + ) + + val messages: List = lintFile(modelFile) + assertEquals(listOf("GeoTargeting.cityId must declare @SerializedName."), messages) + } + + @Test + fun `reports data class used by toJson constructor call`(): Unit { + val modelFile: File = writeSourceFile( + relativePath = "com/example/GeoTargeting.kt", + content = """ + package com.example + + data class GeoTargeting( + val cityId: String, + ) + """.trimIndent() + ) + writeSourceFile( + relativePath = "com/example/GeoSerializationManager.kt", + content = """ + package com.example + + import com.google.gson.Gson + + class GeoSerializationManager(private val gson: Gson) { + fun serialize(): String { + return gson.toJson(GeoTargeting(cityId = "1")) + } + } + """.trimIndent() + ) + + val messages: List = lintFile(modelFile) + assertEquals(listOf("GeoTargeting.cityId must declare @SerializedName."), messages) + } + + @Test + fun `reports data class used by generic fromJson`(): Unit { + val sourceFile: File = writeSourceFile( + relativePath = "com/example/GeoTargeting.kt", + content = """ + package com.example + + data class GeoTargeting( + val cityId: String, + ) + + fun deserialize(json: String): GeoTargeting { + return fromJson(json) + } + """.trimIndent() + ) + + val messages: List = lintFile(sourceFile) + assertEquals(listOf("GeoTargeting.cityId must declare @SerializedName."), messages) + } + + @Test + fun `reports data class used by TypeToken object declaration`(): Unit { + val sourceFile: File = writeSourceFile( + relativePath = "com/example/GeoTargeting.kt", + content = """ + package com.example + + import com.google.gson.reflect.TypeToken + + data class GeoTargeting( + val cityId: String, + ) + + val geoTargetingType = object : TypeToken() {}.type + """.trimIndent() + ) + + val messages: List = lintFile(sourceFile) + assertEquals(listOf("GeoTargeting.cityId must declare @SerializedName."), messages) + } + + @Test + fun `reports data class used by nested TypeToken generic`(): Unit { + val sourceFile: File = writeSourceFile( + relativePath = "com/example/GeoTargeting.kt", + content = """ + package com.example + + import com.google.gson.reflect.TypeToken + + data class GeoTargeting( + val cityId: String, + ) + + val geoTargetingListType = object : TypeToken>() {}.type + """.trimIndent() + ) + + val messages: List = lintFile(sourceFile) + assertEquals(listOf("GeoTargeting.cityId must declare @SerializedName."), messages) + } + + @Test + fun `reports data classes used by wrapper methods`(): Unit { + val sourceFile: File = writeSourceFile( + relativePath = "com/example/GeoTargeting.kt", + content = """ + package com.example + + data class FromJsonModel( + val fromJsonValue: String, + ) + + data class ToJsonModel( + val toJsonValue: String, + ) + + data class OperationBodyModel( + val operationBodyValue: String, + ) + + data class ConvertJsonModel( + val convertJsonValue: String, + ) + + fun useWrappers(json: String): String { + fromJsonTyped(json) + toJsonTyped(ToJsonModel(toJsonValue = "1")) + operationBodyJson(OperationBodyModel(operationBodyValue = "2")) + convertJsonToBody(json, ConvertJsonModel::class.java) + return json + } + """.trimIndent() + ) + + val messages: List = lintFile(sourceFile) + assertEquals( + listOf( + "FromJsonModel.fromJsonValue must declare @SerializedName.", + "ToJsonModel.toJsonValue must declare @SerializedName.", + "OperationBodyModel.operationBodyValue must declare @SerializedName.", + "ConvertJsonModel.convertJsonValue must declare @SerializedName." + ), + messages + ) + } + + @Test + fun `reports data class used only as field type of Gson-serialized class`(): Unit { + val innerFile: File = writeSourceFile( + relativePath = "com/example/InnerData.kt", + content = """ + package com.example + + data class InnerData( + val value: String, + ) + """.trimIndent() + ) + writeSourceFile( + relativePath = "com/example/OuterData.kt", + content = """ + package com.example + + import com.google.gson.annotations.SerializedName + + data class OuterData( + @SerializedName("inner") val inner: InnerData, + ) + """.trimIndent() + ) + writeSourceFile( + relativePath = "com/example/Serializer.kt", + content = """ + package com.example + + import com.google.gson.Gson + + class Serializer { + fun serialize(data: OuterData): String = Gson().toJson(data) + } + """.trimIndent() + ) + + val messages: List = lintFile(innerFile) + assertEquals(listOf("InnerData.value must declare @SerializedName."), messages) + } + + @Test + fun `reports data class reachable via multi-level field nesting`(): Unit { + val deepFile: File = writeSourceFile( + relativePath = "com/example/DeepData.kt", + content = """ + package com.example + + data class DeepData( + val label: String, + ) + """.trimIndent() + ) + writeSourceFile( + relativePath = "com/example/MidData.kt", + content = """ + package com.example + + import com.google.gson.annotations.SerializedName + + data class MidData( + @SerializedName("deep") val deep: DeepData, + ) + """.trimIndent() + ) + writeSourceFile( + relativePath = "com/example/RootData.kt", + content = """ + package com.example + + import com.google.gson.annotations.SerializedName + + data class RootData( + @SerializedName("mid") val mid: MidData, + ) + """.trimIndent() + ) + writeSourceFile( + relativePath = "com/example/Api.kt", + content = """ + package com.example + + import com.google.gson.Gson + + fun serialize(data: RootData): String = Gson().toJson(data) + """.trimIndent() + ) + + val messages: List = lintFile(deepFile) + assertEquals(listOf("DeepData.label must declare @SerializedName."), messages) + } + + @Test + fun `reports data class used as generic field type of Gson-serialized class`(): Unit { + val itemFile: File = writeSourceFile( + relativePath = "com/example/Item.kt", + content = """ + package com.example + + data class Item( + val label: String, + ) + """.trimIndent() + ) + writeSourceFile( + relativePath = "com/example/Container.kt", + content = """ + package com.example + + import com.google.gson.annotations.SerializedName + + data class Container( + @SerializedName("items") val items: List, + ) + """.trimIndent() + ) + writeSourceFile( + relativePath = "com/example/Api.kt", + content = """ + package com.example + + import com.google.gson.Gson + + fun serialize(c: Container): String = Gson().toJson(c) + """.trimIndent() + ) + + val messages: List = lintFile(itemFile) + assertEquals(listOf("Item.label must declare @SerializedName."), messages) + } + + @Test + fun `reports data class that serializes itself via toJson(this)`(): Unit { + val sourceFile: File = writeSourceFile( + relativePath = "com/example/Payload.kt", + content = """ + package com.example + + import com.google.gson.Gson + + data class Payload( + val id: String, + val value: Int, + ) { + fun toJson(): String = Gson().toJson(this) + } + """.trimIndent() + ) + + val messages: List = lintFile(sourceFile) + assertEquals( + listOf( + "Payload.id must declare @SerializedName.", + "Payload.value must declare @SerializedName.", + ), + messages + ) + } + + @Test + fun `does not report data class that is only a field type of a non-Gson class`(): Unit { + val innerFile: File = writeSourceFile( + relativePath = "com/example/InternalState.kt", + content = """ + package com.example + + data class InternalState( + val flag: Boolean, + ) + """.trimIndent() + ) + writeSourceFile( + relativePath = "com/example/ViewModel.kt", + content = """ + package com.example + + data class ViewModel( + val state: InternalState, + ) + """.trimIndent() + ) + + val messages: List = lintFile(innerFile) + assertTrue(messages.isEmpty()) + } + + @Test + fun `reports only missing fields for partially annotated data class`(): Unit { + val sourceFile: File = writeSourceFile( + relativePath = "com/example/PartiallyAnnotated.kt", + content = """ + package com.example + + import com.google.gson.annotations.SerializedName + + data class PartiallyAnnotated( + @SerializedName("id") val id: String, + val name: String, + ) + """.trimIndent() + ) + + val messages: List = lintFile(sourceFile) + assertEquals(listOf("PartiallyAnnotated.name must declare @SerializedName."), messages) + } + + @Test + fun `does not report fully annotated data class`(): Unit { + val sourceFile: File = writeSourceFile( + relativePath = "com/example/FullyAnnotated.kt", + content = """ + package com.example + + import com.google.gson.annotations.SerializedName + + data class FullyAnnotated( + @SerializedName("id") val id: String, + @SerializedName("name") val name: String, + ) + """.trimIndent() + ) + + val messages: List = lintFile(sourceFile) + assertTrue(messages.isEmpty()) + } + + @Test + fun `does not report data class that is not used by Gson`(): Unit { + val sourceFile: File = writeSourceFile( + relativePath = "com/example/InternalState.kt", + content = """ + package com.example + + data class InternalState( + val value: String, + ) + """.trimIndent() + ) + + val messages: List = lintFile(sourceFile) + assertTrue(messages.isEmpty()) + } + + private fun lintFile(file: File): List { + return GsonSerializedNameRule(config = TestConfig()) + .lint(file.toPath()) + .map { finding -> finding.message } + } + + private fun writeSourceFile(relativePath: String, content: String): File { + val sourceFile: File = temporaryFolder.root + .resolve("src/main/java") + .resolve(relativePath) + sourceFile.parentFile.mkdirs() + sourceFile.writeText(content) + return sourceFile + } +} diff --git a/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/UnmonitoredGsonWrapperRuleTest.kt b/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/UnmonitoredGsonWrapperRuleTest.kt new file mode 100644 index 000000000..f78464dcb --- /dev/null +++ b/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/UnmonitoredGsonWrapperRuleTest.kt @@ -0,0 +1,168 @@ +package cloud.mindbox.detekt + +import io.gitlab.arturbosch.detekt.test.TestConfig +import io.gitlab.arturbosch.detekt.test.lint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class UnmonitoredGsonWrapperRuleTest { + + @Test + fun `reports function that wraps fromJson under a new name`() { + val findings = rule.lint( + """ + import com.google.gson.Gson + import com.google.gson.reflect.TypeToken + + fun deserializeModel(json: String, clazz: Class): T = + Gson().fromJson(json, clazz) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertEquals("UnmonitoredGsonWrapper", findings.first().id) + assertTrue(findings.first().message.contains("deserializeModel")) + } + + @Test + fun `reports generic function that wraps toJson under a new name`() { + val findings = rule.lint( + """ + import com.google.gson.Gson + + fun serializePayload(payload: T): String = Gson().toJson(payload) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("serializePayload")) + } + + @Test + fun `does not report non-generic function wrapping Gson`() { + val findings = rule.lint( + """ + import com.google.gson.Gson + + data class Config(val key: String) + fun serializeConfig(config: Config): String = Gson().toJson(config) + """.trimIndent() + ) + // Non-generic: explicit type Config is already detected by GsonMissingSerializedName. + assertTrue(findings.isEmpty()) + } + + @Test + fun `does not report function already monitored by GsonMissingSerializedName`() { + val findings = rule.lint( + """ + import com.google.gson.Gson + + fun toJson(obj: T): String = Gson().toJson(obj) + fun fromJson(json: String, clazz: Class): T = Gson().fromJson(json, clazz) + fun toJsonTyped(obj: T): String = Gson().toJson(obj) + fun fromJsonTyped(json: String, clazz: Class): T = Gson().fromJson(json, clazz) + fun operationBodyJson(obj: T): String = Gson().toJson(obj) + fun convertJsonToBody(json: String, clazz: Class): T = Gson().fromJson(json, clazz) + """.trimIndent() + ) + assertTrue(findings.isEmpty()) + } + + @Test + fun `reports internal generic function wrapping Gson`() { + val findings = rule.lint( + """ + import com.google.gson.Gson + + internal fun loadModel(json: String, clazz: Class): T = Gson().fromJson(json, clazz) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("loadModel")) + } + + @Test + fun `does not report private function wrapping Gson`() { + val findings = rule.lint( + """ + import com.google.gson.Gson + + class Repo { + private fun loadInternal(json: String): Any = Gson().fromJson(json, Any::class.java) + } + """.trimIndent() + ) + assertTrue(findings.isEmpty()) + } + + @Test + fun `does not report override function wrapping Gson`() { + val findings = rule.lint( + """ + import com.google.gson.Gson + + class GeoSerializer : GeoSerializationManager { + override fun deserializeToGeoTargeting(json: String): Any = + Gson().fromJson(json, Any::class.java) + } + """.trimIndent() + ) + assertTrue(findings.isEmpty()) + } + + @Test + fun `does not report function that does not call Gson`() { + val findings = rule.lint( + """ + import com.fasterxml.jackson.databind.ObjectMapper + + fun deserializeWithJackson(json: String): Any = + ObjectMapper().readValue(json, Any::class.java) + """.trimIndent() + ) + assertTrue(findings.isEmpty()) + } + + @Test + fun `reports generic extension function on Gson receiver`() { + val findings = rule.lint( + """ + import com.google.gson.Gson + + fun Gson.parseConfig(json: String, clazz: Class): T = fromJson(json, clazz) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("parseConfig")) + } + + @Test + fun `does not report non-generic extension function on Gson receiver`() { + val findings = rule.lint( + """ + import com.google.gson.Gson + + data class Config(val key: String) + fun Gson.parseConfig(json: String): Config = fromJson(json, Config::class.java) + """.trimIndent() + ) + assertTrue(findings.isEmpty()) + } + + @Test + fun `reports outer generic function when Gson call is inside a nested lambda`() { + val findings = rule.lint( + """ + import com.google.gson.Gson + + fun buildDeserializer(clazz: Class): () -> T = { + Gson().fromJson("{}", clazz) + } + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("buildDeserializer")) + } + + private val rule = UnmonitoredGsonWrapperRule(config = TestConfig()) +} diff --git a/detekt.yml b/detekt.yml new file mode 100644 index 000000000..d0fce63e2 --- /dev/null +++ b/detekt.yml @@ -0,0 +1,37 @@ +config: + validation: true + excludes: "mindbox.*,mindbox>.*>.*" + +comments: + active: false + +complexity: + active: false + +coroutines: + active: false + +empty-blocks: + active: false + +exceptions: + active: false + +naming: + active: false + +performance: + active: false + +potential-bugs: + active: false + +style: + active: false + +mindbox: + active: true + GsonMissingSerializedName: + active: true + UnmonitoredGsonWrapper: + active: true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 604bf1dba..256d8e472 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,7 @@ ktlint-plugin = "12.1.1" ksp = "1.9.22-1.0.17" maven_publish = "0.32.0" kover = "0.8.3" +detekt = "1.23.6" pushclient = "7.2.0" @@ -54,6 +55,7 @@ buildscript-plugins = [ "ksp_gradle_plugin", "maven_publish_plugin", "kover_gradle_plugin", + "detekt_gradle_plugin", ] test = [ @@ -121,4 +123,7 @@ agcp = { module = "com.huawei.agconnect:agcp", version.ref = "agcp" } ktlint_gradle_plugin = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint-plugin" } ksp_gradle_plugin = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "ksp" } maven_publish_plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "maven_publish" } -kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } \ No newline at end of file +kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } +detekt_gradle_plugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } +detekt_api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" } +detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version.ref = "detekt" } diff --git a/modulesCommon.gradle b/modulesCommon.gradle index e1b436a48..54607db36 100644 --- a/modulesCommon.gradle +++ b/modulesCommon.gradle @@ -4,6 +4,7 @@ apply plugin: 'signing' apply plugin: 'org.jlleitschuh.gradle.ktlint' apply plugin: 'com.vanniktech.maven.publish' apply plugin: 'org.jetbrains.kotlinx.kover' +apply plugin: 'io.gitlab.arturbosch.detekt' group = 'com.github.mindbox-cloud' @@ -48,6 +49,16 @@ android { } } +detekt { + buildUponDefaultConfig = true + config.setFrom(files("${rootDir}/detekt.yml")) + source.setFrom(files("src/main/java", "src/main/kotlin")) +} + +dependencies { + detektPlugins project(path: ':detekt-rules', configuration: 'runtimeElements') +} + mavenPublishing { publishToMavenCentral("CENTRAL_PORTAL") diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt index 7f2120877..93aed8336 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt @@ -162,6 +162,7 @@ internal object MindboxEventManager { } } + @Suppress("UnmonitoredGsonWrapper") // body: T is a client subclass of OperationBodyRequestBase — outside SDK control. fun syncOperation( name: String, body: T, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/CustomFields.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/CustomFields.kt index 8439d6758..35f77d0d6 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/CustomFields.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/CustomFields.kt @@ -13,7 +13,12 @@ public class CustomFields(public val fields: Map? = null) { * Convert [CustomFields] value to [T] typed object. * * @param classOfT Class type for result [CustomFields] object. + * + * **Important:** [T] is a caller-supplied type deserialized via Gson. All constructor + * properties of [T] must declare [@SerializedName][com.google.gson.annotations.SerializedName] + * to ensure correct serialization after code shrinking (ProGuard/R8). */ + @Suppress("UnmonitoredGsonWrapper") // T is a caller-supplied type; SDK cannot enforce @SerializedName on it. public fun convertTo(classOfT: Class): T? = LoggingExceptionHandler.runCatching(defaultValue = null) { val gson = Gson() gson.fromJson(gson.toJson(fields), classOfT) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt index d339a096e..6bcd8d344 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt @@ -25,6 +25,8 @@ internal data class PrefPushToken( internal typealias PushTokenMap = Map internal typealias PrefPushTokenMap = Map +// Called only with Map and Map — both covered by GsonMissingSerializedName. +@Suppress("UnmonitoredGsonWrapper") internal fun Map.toPreferences(): String = runCatching { Gson().toJson(this) diff --git a/settings.gradle b/settings.gradle index 18e565307..0ac912068 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,5 @@ include ':sdk' +include ':detekt-rules' include ':mindbox-huawei' include ':mindbox-firebase' include ':mindbox-rustore' From 50b99ecc1b0a8be1592de54fa26d4ee60f3b8128 Mon Sep 17 00:00:00 2001 From: sozinov Date: Mon, 25 May 2026 12:07:03 +0300 Subject: [PATCH 2/5] MOBILE-178: Replace regex-based Gson detection with type resolution --- .github/workflows/lint_unitTests_build.yml | 2 +- .../mindbox/detekt/GsonSerializedNameRule.kt | 155 +---- .../mindbox/detekt/MindboxRuleSetProvider.kt | 7 +- .../detekt/ProjectGsonClassNameProvider.kt | 207 ------- .../detekt/GsonSerializedNameRuleTest.kt | 582 +++++------------- .../inapp/domain/models/GeoTargeting.kt | 8 +- .../domain/models/InAppFailuresWrapper.kt | 3 +- .../operation/response/InAppConfigResponse.kt | 12 +- .../mindbox/mobile_sdk/pushes/PushToken.kt | 5 +- 9 files changed, 198 insertions(+), 783 deletions(-) delete mode 100644 detekt-rules/src/main/kotlin/cloud/mindbox/detekt/ProjectGsonClassNameProvider.kt diff --git a/.github/workflows/lint_unitTests_build.yml b/.github/workflows/lint_unitTests_build.yml index 080eee998..a1a3cc003 100644 --- a/.github/workflows/lint_unitTests_build.yml +++ b/.github/workflows/lint_unitTests_build.yml @@ -51,7 +51,7 @@ jobs: run: ./gradlew --no-daemon lintDebug - name: detekt check - run: ./gradlew --no-daemon detekt + run: ./gradlew --no-daemon detektDebug unit: runs-on: ubuntu-latest diff --git a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt index 10f9259d5..d659e0434 100644 --- a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt +++ b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt @@ -15,18 +15,12 @@ import org.jetbrains.kotlin.psi.KtDotQualifiedExpression import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtObjectDeclaration import org.jetbrains.kotlin.psi.KtParameter -import org.jetbrains.kotlin.psi.KtTypeAlias -import org.jetbrains.kotlin.psi.KtTypeReference -import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType import org.jetbrains.kotlin.resolve.BindingContext import org.jetbrains.kotlin.resolve.DescriptorToSourceUtils import org.jetbrains.kotlin.resolve.calls.util.getType import org.jetbrains.kotlin.types.KotlinType -class GsonSerializedNameRule( - config: Config, - private val projectGsonClassNameProvider: ProjectGsonClassNameProvider = ProjectGsonClassNameProvider() -) : Rule(config) { +class GsonSerializedNameRule(config: Config) : Rule(config) { override val issue: Issue = Issue( id = "GsonMissingSerializedName", @@ -42,16 +36,10 @@ class GsonSerializedNameRule( reportedParameterKeys.clear() } - override fun visitKtFile(file: KtFile) { - super.visitKtFile(file) - checkGsonTypeReferences(file) - } - override fun visitClass(klass: KtClass) { super.visitClass(klass) if (!klass.isData()) return - val className = klass.name ?: return - if (!klass.hasSerializedNameContract() && className !in findProjectGsonClassNames(klass.containingKtFile)) return + if (!klass.hasSerializedNameContract()) return reportMissingSerializedNameParameters(klass) } @@ -66,20 +54,25 @@ class GsonSerializedNameRule( override fun visitObjectDeclaration(declaration: KtObjectDeclaration) { super.visitObjectDeclaration(declaration) declaration.superTypeListEntries - .filter { superTypeEntry -> superTypeEntry.text.contains(TYPE_TOKEN) } - .flatMap { superTypeEntry -> extractModelNamesFromTypeText(superTypeEntry.text, declaration.containingKtFile) } - .forEach { className -> findClassByName(declaration.containingKtFile, className)?.let(::reportMissingSerializedNameParameters) } + .filter { entry -> entry.typeReference?.text?.contains(TYPE_TOKEN) == true } + .forEach { entry -> + val type = bindingContext[BindingContext.TYPE, entry.typeReference] ?: return@forEach + type.arguments + .filterNot { projection -> projection.isStarProjection } + .forEach { projection -> checkKotlinType(projection.type) } + } } private fun checkFirstArgumentType(expression: KtCallExpression) { val argument = expression.valueArguments.firstOrNull()?.getArgumentExpression() ?: return - checkKotlinType(type = argument.getType(bindingContext), source = expression) + checkKotlinType(argument.getType(bindingContext) ?: return) } private fun checkTypeArguments(expression: KtCallExpression) { expression.typeArguments .mapNotNull { typeProjection -> typeProjection.typeReference } - .forEach { typeReference -> checkTypeReference(typeReference, expression) } + .mapNotNull { typeRef -> bindingContext[BindingContext.TYPE, typeRef] } + .forEach { type -> checkKotlinType(type) } } private fun checkClassLiteralArguments(expression: KtCallExpression) { @@ -90,43 +83,23 @@ class GsonSerializedNameRule( is KtDotQualifiedExpression -> argument.receiverExpression as? KtClassLiteralExpression is KtClassLiteralExpression -> argument else -> null - } - classLiteral?.let { literal -> checkKotlinType(type = literal.getType(bindingContext), source = expression) } + } ?: return@forEach + // KClass or Class — first type argument is T + val type = classLiteral.getType(bindingContext) ?: return@forEach + type.arguments.firstOrNull() + ?.takeIf { projection -> !projection.isStarProjection } + ?.type + ?.let(::checkKotlinType) } } - private fun checkTypeReference(typeReference: KtTypeReference, source: KtCallExpression) { - checkKotlinType(type = bindingContext[BindingContext.TYPE, typeReference], source = source) - } - - private fun checkGsonTypeReferences(file: KtFile) { - val aliases = file.extractTypeAliases() - val dataClasses = file.collectDescendantsOfType() - .filter { klass -> klass.isData() } - .associateBy { klass -> klass.name.orEmpty() } - val referencedTypeTexts = ( - TYPE_TOKEN_PATTERN.findAll(file.text).map { match -> match.groupValues[1] } + - GSON_GENERIC_CALL_PATTERN.findAll(file.text).map { match -> match.groupValues[1] } + - GSON_CLASS_LITERAL_PATTERN.findAll(file.text).map { match -> match.groupValues[1] } - ).toList() - referencedTypeTexts - .flatMap { typeText -> extractModelNamesFromTypeText(typeText, aliases) } - .mapNotNull { className -> dataClasses[className] } - .forEach(::reportMissingSerializedNameParameters) - } - - private fun findProjectGsonClassNames(file: KtFile): Set { - return projectGsonClassNameProvider.findProjectGsonClassNames(file) - } - - private fun checkKotlinType(type: KotlinType?, source: KtCallExpression) { - type ?: return - val descriptor = type.constructor.declarationDescriptor as? ClassDescriptor - val sourceClass = descriptor?.let { DescriptorToSourceUtils.descriptorToDeclaration(it) } as? KtClass + private fun checkKotlinType(type: KotlinType) { + val descriptor = type.constructor.declarationDescriptor as? ClassDescriptor ?: return + val sourceClass = DescriptorToSourceUtils.descriptorToDeclaration(descriptor) as? KtClass sourceClass?.let(::reportMissingSerializedNameParameters) type.arguments .filterNot { projection -> projection.isStarProjection } - .forEach { projection -> checkKotlinType(type = projection.type, source = source) } + .forEach { projection -> checkKotlinType(projection.type) } } private fun reportMissingSerializedNameParameters(klass: KtClass) { @@ -139,19 +112,10 @@ class GsonSerializedNameRule( if (!parameter.hasSerializedNameAnnotation()) reportParameter(parameter, klass) val typeRef = parameter.typeReference ?: return@forEach val type = bindingContext[BindingContext.TYPE, typeRef] ?: return@forEach - checkKotlinTypeTransitively(type) + checkKotlinType(type) } } - private fun checkKotlinTypeTransitively(type: KotlinType) { - val descriptor = type.constructor.declarationDescriptor as? ClassDescriptor ?: return - val sourceClass = DescriptorToSourceUtils.descriptorToDeclaration(descriptor) as? KtClass - sourceClass?.let(::reportMissingSerializedNameParameters) - type.arguments - .filterNot { projection -> projection.isStarProjection } - .forEach { projection -> checkKotlinTypeTransitively(projection.type) } - } - private fun reportParameter(parameter: KtParameter, klass: KtClass) { val key = "${parameter.containingFile.name}:${parameter.textOffset}" if (!reportedParameterKeys.add(key)) return @@ -174,39 +138,6 @@ class GsonSerializedNameRule( } } - private fun KtFile.extractTypeAliases(): Map { - val psiAliases = declarations - .filterIsInstance() - .associate { alias -> alias.name.orEmpty() to alias.getTypeReference()?.text.orEmpty() } - val textAliases = TYPE_ALIAS_PATTERN.findAll(text) - .associate { match -> match.groupValues[1] to match.groupValues[2].trim() } - return psiAliases + textAliases - } - - private fun extractModelNamesFromTypeText(typeText: String, file: KtFile): Set { - val aliases = file.extractTypeAliases() - return extractModelNamesFromTypeText(typeText, aliases) - } - - private fun extractModelNamesFromTypeText(typeText: String, aliases: Map): Set { - val normalizedText = aliases.entries - .sortedByDescending { alias -> alias.key.length } - .fold(typeText) { currentText, alias -> - currentText.replace(Regex("\\b${Regex.escape(alias.key)}\\b"), alias.value) - } - return MODEL_NAME_PATTERN - .findAll(normalizedText) - .map { match -> match.value.substringAfterLast('.') } - .filter { className -> className !in IGNORED_TYPE_NAMES } - .filter { className -> className.firstOrNull()?.isUpperCase() == true } - .toSet() - } - - private fun findClassByName(file: KtFile, className: String): KtClass? { - return file.collectDescendantsOfType() - .firstOrNull { klass -> klass.name == className } - } - internal companion object { private const val SERIALIZED_NAME = "SerializedName" private const val TYPE_TOKEN = "TypeToken" @@ -221,43 +152,5 @@ class GsonSerializedNameRule( "toJson", "toJsonTyped", ) - - private val GSON_CLASS_LITERAL_PATTERN: Regex = Regex( - "\\b(?:fromJson|convertJsonToBody)\\s*\\([^\\n)]*?([A-Z][A-Za-z0-9_]*)::class\\.java" - ) - private val GSON_GENERIC_CALL_PATTERN: Regex = Regex( - "\\b(?:fromJson|fromJsonTyped|toJsonTyped|operationBodyJson)\\s*<\\s*([^>]+)>" - ) - private val MODEL_NAME_PATTERN: Regex = Regex("[A-Za-z_][A-Za-z0-9_.]*") - private val TYPE_ALIAS_PATTERN: Regex = Regex("typealias\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*([^\\n]+)") - private val TYPE_TOKEN_PATTERN: Regex = Regex("TypeToken\\s*<\\s*([^>]+)>") - - private val IGNORED_TYPE_NAMES: Set = setOf( - "Any", - "Array", - "Boolean", - "Byte", - "Char", - "Collection", - "Double", - "Float", - "HashMap", - "HashSet", - "Int", - "Iterable", - "List", - "Long", - "Map", - "MutableList", - "MutableMap", - "MutableSet", - "Number", - "Pair", - "Set", - "Short", - "String", - "TypeToken", - "Unit", - ) } } diff --git a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/MindboxRuleSetProvider.kt b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/MindboxRuleSetProvider.kt index dd37c7645..255b5f0dd 100644 --- a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/MindboxRuleSetProvider.kt +++ b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/MindboxRuleSetProvider.kt @@ -8,16 +8,11 @@ class MindboxRuleSetProvider : RuleSetProvider { override val ruleSetId: String = "mindbox" - private val projectGsonClassNameProvider: ProjectGsonClassNameProvider = ProjectGsonClassNameProvider() - override fun instance(config: Config): RuleSet { return RuleSet( id = ruleSetId, rules = listOf( - GsonSerializedNameRule( - config = config, - projectGsonClassNameProvider = projectGsonClassNameProvider - ), + GsonSerializedNameRule(config = config), UnmonitoredGsonWrapperRule(config = config), ) ) diff --git a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/ProjectGsonClassNameProvider.kt b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/ProjectGsonClassNameProvider.kt deleted file mode 100644 index e791d8271..000000000 --- a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/ProjectGsonClassNameProvider.kt +++ /dev/null @@ -1,207 +0,0 @@ -package cloud.mindbox.detekt - -import org.jetbrains.kotlin.psi.KtFile -import java.io.File - -class ProjectGsonClassNameProvider { - - private val gsonClassNamesBySourceRoot: MutableMap> = mutableMapOf() - - fun findProjectGsonClassNames(file: KtFile): Set { - val sourceRoot: File = file.findSourceRoot() ?: return emptySet() - return gsonClassNamesBySourceRoot.getOrPut(sourceRoot.absolutePath) { - sourceRoot.walkTopDown() - .filter { sourceFile: File -> sourceFile.isFile && sourceFile.extension == KOTLIN_EXTENSION } - .map { sourceFile: File -> sourceFile.readText() } - .let(::extractProjectGsonClassNames) - } - } - - internal fun extractProjectGsonClassNames(fileTexts: Sequence): Set { - val texts: List = fileTexts.toList() - val aliases: Map = texts - .flatMap { text: String -> - TYPE_ALIAS_PATTERN.findAll(text).map { match: MatchResult -> - match.groupValues[1] to match.groupValues[2].trim() - } - } - .toMap() - val directNames: MutableSet = texts - .flatMap { text: String -> extractSerializedTypeTexts(text) } - .flatMap { typeText: String -> extractModelNamesFromTypeText(typeText, aliases) } - .toMutableSet() - val fieldTypeMap: Map> = buildDataClassFieldTypeMap(texts, aliases) - var changed = true - while (changed) { - changed = false - for (name in directNames.toSet()) { - fieldTypeMap[name]?.forEach { fieldType: String -> - if (directNames.add(fieldType)) changed = true - } - } - } - return directNames - } - - private fun extractSerializedTypeTexts(text: String): List { - val explicitlySerializedTypes: Sequence = - TYPE_TOKEN_PATTERN.findAll(text).map { match: MatchResult -> match.groupValues[1] } + - GSON_GENERIC_CALL_PATTERN.findAll(text).map { match: MatchResult -> match.groupValues[1] } + - GSON_CLASS_LITERAL_PATTERN.findAll(text).map { match: MatchResult -> match.groupValues[1] } + - TO_JSON_CONSTRUCTOR_PATTERN.findAll(text).map { match: MatchResult -> match.groupValues[1] } + - extractToJsonThisClassNames(text).asSequence() - val variableTypes: Map = extractTypedVariableNames(text) - val toJsonVariableTypes: Sequence = TO_JSON_VARIABLE_PATTERN.findAll(text) - .mapNotNull { match: MatchResult -> variableTypes[match.groupValues[1]] } - return (explicitlySerializedTypes + toJsonVariableTypes).toList() - } - - private fun extractToJsonThisClassNames(text: String): List { - return TO_JSON_THIS_PATTERN.findAll(text).mapNotNull { match: MatchResult -> - val textBefore: String = text.substring(0, match.range.first) - PRECEDING_CLASS_NAME_PATTERN.findAll(textBefore).lastOrNull()?.groupValues?.get(1) - }.toList() - } - - private fun buildDataClassFieldTypeMap( - texts: List, - aliases: Map, - ): Map> { - val result: MutableMap> = mutableMapOf() - for (text in texts) { - var searchPos = 0 - while (true) { - val headerMatch: MatchResult = DATA_CLASS_HEADER_PATTERN.find(text, searchPos) ?: break - val className: String = headerMatch.groupValues[1] - val afterHeader: Int = headerMatch.range.last + 1 - val parenStart: Int = text.indexOf('(', afterHeader) - if (parenStart < 0 || parenStart - afterHeader > 100) { - searchPos = afterHeader - continue - } - val constructorBody: String? = extractMatchingParenContent(text, parenStart) - if (constructorBody != null) { - CONSTRUCTOR_FIELD_LINE_PATTERN.findAll(constructorBody) - .flatMap { match: MatchResult -> - extractModelNamesFromTypeText(match.groupValues[1], aliases) - } - .forEach { fieldType: String -> - result.getOrPut(className) { mutableSetOf() }.add(fieldType) - } - } - searchPos = afterHeader - } - } - return result - } - - private fun extractMatchingParenContent(text: String, openParenPos: Int): String? { - var depth = 1 - var i = openParenPos + 1 - while (i < text.length && depth > 0) { - when (text[i]) { - '(' -> depth++ - ')' -> depth-- - } - i++ - } - return if (depth == 0) text.substring(openParenPos + 1, i - 1) else null - } - - private fun extractTypedVariableNames(text: String): Map { - val propertyTypes: Sequence> = VARIABLE_DECLARATION_TYPE_PATTERN.findAll(text) - .map { match: MatchResult -> match.groupValues[1] to match.groupValues[2] } - val functionParameterTypes: Sequence> = FUNCTION_PARAMETERS_PATTERN.findAll(text) - .flatMap { match: MatchResult -> - PARAMETER_DECLARATION_TYPE_PATTERN.findAll(match.groupValues[1]) - .map { parameterMatch: MatchResult -> - parameterMatch.groupValues[1] to parameterMatch.groupValues[2] - } - } - return (propertyTypes + functionParameterTypes).toMap() - } - - private fun extractModelNamesFromTypeText(typeText: String, aliases: Map): Set { - val normalizedText: String = aliases.entries - .sortedByDescending { alias: Map.Entry -> alias.key.length } - .fold(typeText) { currentText: String, alias: Map.Entry -> - currentText.replace(Regex("\\b${Regex.escape(alias.key)}\\b"), alias.value) - } - return MODEL_NAME_PATTERN - .findAll(normalizedText) - .map { match: MatchResult -> match.value.substringAfterLast('.') } - .filter { className: String -> className !in IGNORED_TYPE_NAMES } - .filter { className: String -> className.firstOrNull()?.isUpperCase() == true } - .toSet() - } - - private fun KtFile.findSourceRoot(): File? { - val path: String = virtualFile?.path ?: return null - val sourceRootPath: String = SOURCE_ROOT_MARKERS - .firstNotNullOfOrNull { marker: String -> - path.substringBefore(marker, missingDelimiterValue = "") - .takeIf { rootPrefix: String -> rootPrefix.isNotEmpty() } - ?.let { rootPrefix: String -> rootPrefix + MAIN_SOURCE_SET } - } ?: return null - return File(sourceRootPath) - } - - private companion object { - private const val KOTLIN_EXTENSION = "kt" - private const val MAIN_SOURCE_SET = "/src/main" - - private val GSON_CLASS_LITERAL_PATTERN: Regex = Regex( - "\\b(?:fromJson|convertJsonToBody)\\s*\\([^\\n)]*?([A-Z][A-Za-z0-9_]*)::class\\.java" - ) - private val GSON_GENERIC_CALL_PATTERN: Regex = Regex( - "\\b(?:fromJson|fromJsonTyped|toJsonTyped|operationBodyJson)\\s*<\\s*([^>]+)>" - ) - private val MODEL_NAME_PATTERN: Regex = Regex("[A-Za-z_][A-Za-z0-9_.]*") - private val SOURCE_ROOT_MARKERS: List = listOf("/src/main/java/", "/src/main/kotlin/") - private val TO_JSON_CONSTRUCTOR_PATTERN: Regex = Regex("\\btoJson\\s*\\(\\s*([A-Z][A-Za-z0-9_]*)\\s*\\(") - private val TO_JSON_VARIABLE_PATTERN: Regex = Regex("\\btoJson\\s*\\(\\s*([a-zA-Z_][A-Za-z0-9_]*)\\s*(?:,|\\))") - private val TYPE_ALIAS_PATTERN: Regex = Regex("typealias\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*=\\s*([^\\n]+)") - private val TYPE_TOKEN_PATTERN: Regex = Regex("TypeToken\\s*<\\s*([^>]+)>") - private val FUNCTION_PARAMETERS_PATTERN: Regex = Regex("\\bfun\\s+[A-Za-z_][A-Za-z0-9_]*\\s*\\(([^)]*)\\)") - private val PARAMETER_DECLARATION_TYPE_PATTERN: Regex = Regex( - "(?:^|,)\\s*([a-zA-Z_][A-Za-z0-9_]*)\\s*:\\s*([A-Z][A-Za-z0-9_.]*(?:<[^>\\n]+>)?)" - ) - private val VARIABLE_DECLARATION_TYPE_PATTERN: Regex = Regex( - "\\b(?:val|var)\\s+([a-zA-Z_][A-Za-z0-9_]*)\\s*:\\s*([A-Z][A-Za-z0-9_.]*(?:<[^>\\n]+>)?)" - ) - private val DATA_CLASS_HEADER_PATTERN: Regex = Regex("data\\s+class\\s+([A-Za-z_][A-Za-z0-9_]*)") - private val CONSTRUCTOR_FIELD_LINE_PATTERN: Regex = Regex( - "(?:val|var)\\s+[a-zA-Z_][A-Za-z0-9_]*\\s*:\\s*([^\\n=]+)" - ) - private val TO_JSON_THIS_PATTERN: Regex = Regex("\\btoJson\\s*\\(\\s*this\\s*[,)]") - private val PRECEDING_CLASS_NAME_PATTERN: Regex = Regex("\\bclass\\s+([A-Z][A-Za-z0-9_]*)") - - private val IGNORED_TYPE_NAMES: Set = setOf( - "Any", - "Array", - "Boolean", - "Byte", - "Char", - "Collection", - "Double", - "Float", - "HashMap", - "HashSet", - "Int", - "Iterable", - "List", - "Long", - "Map", - "MutableList", - "MutableMap", - "MutableSet", - "Number", - "Pair", - "Set", - "Short", - "String", - "TypeToken", - "Unit", - ) - } -} diff --git a/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/GsonSerializedNameRuleTest.kt b/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/GsonSerializedNameRuleTest.kt index 350c9aa51..88bf1fb67 100644 --- a/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/GsonSerializedNameRuleTest.kt +++ b/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/GsonSerializedNameRuleTest.kt @@ -1,519 +1,249 @@ package cloud.mindbox.detekt +import io.github.detekt.test.utils.createEnvironment +import io.gitlab.arturbosch.detekt.api.Finding import io.gitlab.arturbosch.detekt.test.TestConfig -import io.gitlab.arturbosch.detekt.test.lint +import io.gitlab.arturbosch.detekt.test.compileAndLintWithContext +import org.junit.AfterClass import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue -import org.junit.Rule import org.junit.Test -import org.junit.rules.TemporaryFolder -import java.io.File class GsonSerializedNameRuleTest { - @get:Rule - val temporaryFolder: TemporaryFolder = TemporaryFolder() + private val stubs = """ + fun fromJson(json: String, clazz: Class<*>): Any = error("stub") + fun fromJson(json: String, clazz: Class): T = error("stub") + fun toJson(obj: Any?): String = error("stub") + fun toJsonTyped(obj: T): String = error("stub") + fun fromJsonTyped(json: String, clazz: Class): T = error("stub") + fun operationBodyJson(obj: T): String = error("stub") + fun convertJsonToBody(json: String, clazz: Class<*>): Any = error("stub") + abstract class TypeToken + annotation class SerializedName(val value: String) + """.trimIndent() - @Test - fun `reports data class used by Gson class literal in another file`(): Unit { - val modelFile: File = writeSourceFile( - relativePath = "com/example/GeoTargeting.kt", - content = """ - package com.example - - data class GeoTargeting( - val cityId: String, - val regionId: String, - val countryId: String, - ) - """.trimIndent() - ) - writeSourceFile( - relativePath = "com/example/GeoSerializationManager.kt", - content = """ - package com.example - - import com.google.gson.Gson + private fun compileAndLint(code: String): List = + GsonSerializedNameRule(TestConfig()).compileAndLintWithContext(env, code) - class GeoSerializationManager(private val gson: Gson) { - fun deserialize(json: String): GeoTargeting { - return gson.fromJson(json, GeoTargeting::class.java) - } - } + @Test + fun `reports class passed via class literal to fromJson`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto(val name: String) + fun test(json: String): Any = fromJson(json, MyDto::class.java) """.trimIndent() ) - - val messages: List = lintFile(modelFile) - assertEquals( - listOf( - "GeoTargeting.cityId must declare @SerializedName.", - "GeoTargeting.regionId must declare @SerializedName.", - "GeoTargeting.countryId must declare @SerializedName." - ), - messages - ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.name")) } @Test - fun `reports data class used by TypeToken typealias`(): Unit { - val sourceFile: File = writeSourceFile( - relativePath = "com/example/PushToken.kt", - content = """ - package com.example - - import com.google.gson.Gson - import com.google.gson.reflect.TypeToken - - data class PrefPushToken( - val token: String, - val updateDate: Long, - ) - - typealias PushTokenMap = Map - typealias PrefPushTokenMap = Map - - fun decode(json: String): PrefPushTokenMap { - val type = object : TypeToken() {}.type - return Gson().fromJson(json, type) - } + fun `reports class passed as explicit type argument to fromJson`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto(val name: String) + fun test(json: String): MyDto = fromJson(json, MyDto::class.java) """.trimIndent() ) - - val messages: List = lintFile(sourceFile) - assertEquals( - listOf( - "PrefPushToken.token must declare @SerializedName.", - "PrefPushToken.updateDate must declare @SerializedName." - ), - messages - ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.name")) } @Test - fun `reports data class used by typed toJson variable`(): Unit { - val modelFile: File = writeSourceFile( - relativePath = "com/example/GeoTargeting.kt", - content = """ - package com.example - - data class GeoTargeting( - val cityId: String, - ) - """.trimIndent() - ) - writeSourceFile( - relativePath = "com/example/GeoSerializationManager.kt", - content = """ - package com.example - - import com.google.gson.Gson - - class GeoSerializationManager(private val gson: Gson) { - fun serialize(inAppGeo: GeoTargeting): String { - return gson.toJson(inAppGeo) - } - } + fun `reports class passed as first argument to toJson`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto(val name: String) + fun test() = toJson(MyDto("x")) """.trimIndent() ) - - val messages: List = lintFile(modelFile) - assertEquals(listOf("GeoTargeting.cityId must declare @SerializedName."), messages) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.name")) } @Test - fun `reports data class used by toJson constructor call`(): Unit { - val modelFile: File = writeSourceFile( - relativePath = "com/example/GeoTargeting.kt", - content = """ - package com.example - - data class GeoTargeting( - val cityId: String, - ) - """.trimIndent() - ) - writeSourceFile( - relativePath = "com/example/GeoSerializationManager.kt", - content = """ - package com.example - - import com.google.gson.Gson - - class GeoSerializationManager(private val gson: Gson) { - fun serialize(): String { - return gson.toJson(GeoTargeting(cityId = "1")) - } - } + fun `reports class when toJson called with this inside the class`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto(val name: String) { + fun serialize() = toJson(this) + } """.trimIndent() ) - - val messages: List = lintFile(modelFile) - assertEquals(listOf("GeoTargeting.cityId must declare @SerializedName."), messages) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.name")) } @Test - fun `reports data class used by generic fromJson`(): Unit { - val sourceFile: File = writeSourceFile( - relativePath = "com/example/GeoTargeting.kt", - content = """ - package com.example - - data class GeoTargeting( - val cityId: String, - ) - - fun deserialize(json: String): GeoTargeting { - return fromJson(json) - } + fun `reports class passed via operationBodyJson`() { + val findings = compileAndLint( + """ + $stubs + data class RequestBody(val action: String) + fun test() = operationBodyJson(RequestBody("click")) """.trimIndent() ) - - val messages: List = lintFile(sourceFile) - assertEquals(listOf("GeoTargeting.cityId must declare @SerializedName."), messages) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("RequestBody.action")) } @Test - fun `reports data class used by TypeToken object declaration`(): Unit { - val sourceFile: File = writeSourceFile( - relativePath = "com/example/GeoTargeting.kt", - content = """ - package com.example - - import com.google.gson.reflect.TypeToken - - data class GeoTargeting( - val cityId: String, - ) - - val geoTargetingType = object : TypeToken() {}.type + fun `reports class passed via fromJsonTyped class literal`() { + val findings = compileAndLint( + """ + $stubs + data class ResponseBody(val status: String) + fun test(json: String): ResponseBody = fromJsonTyped(json, ResponseBody::class.java) """.trimIndent() ) - - val messages: List = lintFile(sourceFile) - assertEquals(listOf("GeoTargeting.cityId must declare @SerializedName."), messages) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("ResponseBody.status")) } @Test - fun `reports data class used by nested TypeToken generic`(): Unit { - val sourceFile: File = writeSourceFile( - relativePath = "com/example/GeoTargeting.kt", - content = """ - package com.example - - import com.google.gson.reflect.TypeToken - - data class GeoTargeting( - val cityId: String, - ) - - val geoTargetingListType = object : TypeToken>() {}.type + fun `reports class passed via convertJsonToBody`() { + val findings = compileAndLint( + """ + $stubs + data class EventBody(val type: String) + fun test(json: String): Any = convertJsonToBody(json, EventBody::class.java) """.trimIndent() ) - - val messages: List = lintFile(sourceFile) - assertEquals(listOf("GeoTargeting.cityId must declare @SerializedName."), messages) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("EventBody.type")) } @Test - fun `reports data classes used by wrapper methods`(): Unit { - val sourceFile: File = writeSourceFile( - relativePath = "com/example/GeoTargeting.kt", - content = """ - package com.example - - data class FromJsonModel( - val fromJsonValue: String, - ) - - data class ToJsonModel( - val toJsonValue: String, - ) - - data class OperationBodyModel( - val operationBodyValue: String, - ) - - data class ConvertJsonModel( - val convertJsonValue: String, - ) - - fun useWrappers(json: String): String { - fromJsonTyped(json) - toJsonTyped(ToJsonModel(toJsonValue = "1")) - operationBodyJson(OperationBodyModel(operationBodyValue = "2")) - convertJsonToBody(json, ConvertJsonModel::class.java) - return json - } + fun `reports class used as direct TypeToken generic argument`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto(val name: String) + val token = object : TypeToken() {} """.trimIndent() ) - - val messages: List = lintFile(sourceFile) - assertEquals( - listOf( - "FromJsonModel.fromJsonValue must declare @SerializedName.", - "ToJsonModel.toJsonValue must declare @SerializedName.", - "OperationBodyModel.operationBodyValue must declare @SerializedName.", - "ConvertJsonModel.convertJsonValue must declare @SerializedName." - ), - messages - ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.name")) } @Test - fun `reports data class used only as field type of Gson-serialized class`(): Unit { - val innerFile: File = writeSourceFile( - relativePath = "com/example/InnerData.kt", - content = """ - package com.example - - data class InnerData( - val value: String, - ) - """.trimIndent() - ) - writeSourceFile( - relativePath = "com/example/OuterData.kt", - content = """ - package com.example - - import com.google.gson.annotations.SerializedName - - data class OuterData( - @SerializedName("inner") val inner: InnerData, - ) - """.trimIndent() - ) - writeSourceFile( - relativePath = "com/example/Serializer.kt", - content = """ - package com.example - - import com.google.gson.Gson - - class Serializer { - fun serialize(data: OuterData): String = Gson().toJson(data) - } + fun `reports class nested inside List in TypeToken`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto(val name: String) + val token = object : TypeToken>() {} """.trimIndent() ) - - val messages: List = lintFile(innerFile) - assertEquals(listOf("InnerData.value must declare @SerializedName."), messages) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.name")) } @Test - fun `reports data class reachable via multi-level field nesting`(): Unit { - val deepFile: File = writeSourceFile( - relativePath = "com/example/DeepData.kt", - content = """ - package com.example - - data class DeepData( - val label: String, - ) - """.trimIndent() - ) - writeSourceFile( - relativePath = "com/example/MidData.kt", - content = """ - package com.example - - import com.google.gson.annotations.SerializedName - - data class MidData( - @SerializedName("deep") val deep: DeepData, - ) - """.trimIndent() - ) - writeSourceFile( - relativePath = "com/example/RootData.kt", - content = """ - package com.example - - import com.google.gson.annotations.SerializedName - - data class RootData( - @SerializedName("mid") val mid: MidData, - ) - """.trimIndent() - ) - writeSourceFile( - relativePath = "com/example/Api.kt", - content = """ - package com.example - - import com.google.gson.Gson - - fun serialize(data: RootData): String = Gson().toJson(data) + fun `reports transitive field type missing annotation`() { + val findings = compileAndLint( + """ + $stubs + data class Inner(val value: Int) + data class Outer(val inner: Inner) + fun test() = toJson(Outer(Inner(1))) """.trimIndent() ) - - val messages: List = lintFile(deepFile) - assertEquals(listOf("DeepData.label must declare @SerializedName."), messages) + assertEquals(2, findings.size) + assertTrue(findings.any { it.message.contains("Outer.inner") }) + assertTrue(findings.any { it.message.contains("Inner.value") }) } @Test - fun `reports data class used as generic field type of Gson-serialized class`(): Unit { - val itemFile: File = writeSourceFile( - relativePath = "com/example/Item.kt", - content = """ - package com.example - - data class Item( - val label: String, - ) - """.trimIndent() - ) - writeSourceFile( - relativePath = "com/example/Container.kt", - content = """ - package com.example - - import com.google.gson.annotations.SerializedName - - data class Container( - @SerializedName("items") val items: List, - ) - """.trimIndent() - ) - writeSourceFile( - relativePath = "com/example/Api.kt", - content = """ - package com.example - - import com.google.gson.Gson - - fun serialize(c: Container): String = Gson().toJson(c) + fun `reports class nested inside generic List field transitively`() { + val findings = compileAndLint( + """ + $stubs + data class Item(val id: Int) + data class Page(val items: List) + fun test() = toJson(Page(emptyList())) """.trimIndent() ) - - val messages: List = lintFile(itemFile) - assertEquals(listOf("Item.label must declare @SerializedName."), messages) + assertTrue(findings.any { it.message.contains("Page.items") }) + assertTrue(findings.any { it.message.contains("Item.id") }) } @Test - fun `reports data class that serializes itself via toJson(this)`(): Unit { - val sourceFile: File = writeSourceFile( - relativePath = "com/example/Payload.kt", - content = """ - package com.example - - import com.google.gson.Gson - - data class Payload( - val id: String, - val value: Int, - ) { - fun toJson(): String = Gson().toJson(this) - } + fun `reports missing annotation when class has partial SerializedName contract`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto( + @SerializedName("name") val name: String, + val age: Int + ) """.trimIndent() ) - - val messages: List = lintFile(sourceFile) - assertEquals( - listOf( - "Payload.id must declare @SerializedName.", - "Payload.value must declare @SerializedName.", - ), - messages - ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("MyDto.age")) } @Test - fun `does not report data class that is only a field type of a non-Gson class`(): Unit { - val innerFile: File = writeSourceFile( - relativePath = "com/example/InternalState.kt", - content = """ - package com.example - - data class InternalState( - val flag: Boolean, - ) - """.trimIndent() - ) - writeSourceFile( - relativePath = "com/example/ViewModel.kt", - content = """ - package com.example - - data class ViewModel( - val state: InternalState, - ) + fun `does not report data class not involved in any Gson call`() { + val findings = compileAndLint( + """ + $stubs + data class Config(val key: String, val value: Int) """.trimIndent() ) - - val messages: List = lintFile(innerFile) - assertTrue(messages.isEmpty()) + assertTrue(findings.isEmpty()) } @Test - fun `reports only missing fields for partially annotated data class`(): Unit { - val sourceFile: File = writeSourceFile( - relativePath = "com/example/PartiallyAnnotated.kt", - content = """ - package com.example - - import com.google.gson.annotations.SerializedName - - data class PartiallyAnnotated( - @SerializedName("id") val id: String, - val name: String, - ) + fun `does not report when all fields have SerializedName`() { + val findings = compileAndLint( + """ + $stubs + data class MyDto( + @SerializedName("name") val name: String, + @SerializedName("age") val age: Int + ) + fun test() = toJson(MyDto("x", 1)) """.trimIndent() ) - - val messages: List = lintFile(sourceFile) - assertEquals(listOf("PartiallyAnnotated.name must declare @SerializedName."), messages) + assertTrue(findings.isEmpty()) } @Test - fun `does not report fully annotated data class`(): Unit { - val sourceFile: File = writeSourceFile( - relativePath = "com/example/FullyAnnotated.kt", - content = """ - package com.example - - import com.google.gson.annotations.SerializedName - - data class FullyAnnotated( - @SerializedName("id") val id: String, - @SerializedName("name") val name: String, - ) + fun `does not report non-data class passed to toJson`() { + val findings = compileAndLint( + """ + $stubs + class NotADataClass(val name: String) + fun test() = toJson(NotADataClass("x")) """.trimIndent() ) - - val messages: List = lintFile(sourceFile) - assertTrue(messages.isEmpty()) + assertTrue(findings.isEmpty()) } @Test - fun `does not report data class that is not used by Gson`(): Unit { - val sourceFile: File = writeSourceFile( - relativePath = "com/example/InternalState.kt", - content = """ - package com.example - - data class InternalState( - val value: String, - ) + fun `does not report stdlib types like String or List`() { + val findings = compileAndLint( + """ + $stubs + fun test() = toJson(listOf("a", "b")) """.trimIndent() ) - - val messages: List = lintFile(sourceFile) - assertTrue(messages.isEmpty()) + assertTrue(findings.isEmpty()) } - private fun lintFile(file: File): List { - return GsonSerializedNameRule(config = TestConfig()) - .lint(file.toPath()) - .map { finding -> finding.message } - } + companion object { + private val environmentWrapper = createEnvironment() + private val env get() = environmentWrapper.env - private fun writeSourceFile(relativePath: String, content: String): File { - val sourceFile: File = temporaryFolder.root - .resolve("src/main/java") - .resolve(relativePath) - sourceFile.parentFile.mkdirs() - sourceFile.writeText(content) - return sourceFile + @AfterClass @JvmStatic + fun tearDown() { + environmentWrapper.dispose() + } } } diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/GeoTargeting.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/GeoTargeting.kt index 1bb81a18a..f58fca729 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/GeoTargeting.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/GeoTargeting.kt @@ -1,7 +1,9 @@ package cloud.mindbox.mobile_sdk.inapp.domain.models +import com.google.gson.annotations.SerializedName + internal data class GeoTargeting( - val cityId: String, - val regionId: String, - val countryId: String, + @SerializedName("cityId") val cityId: String, + @SerializedName("regionId") val regionId: String, + @SerializedName("countryId") val countryId: String, ) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt index 2596787a6..c14263295 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/inapp/domain/models/InAppFailuresWrapper.kt @@ -1,7 +1,8 @@ package cloud.mindbox.mobile_sdk.inapp.domain.models import cloud.mindbox.mobile_sdk.models.operation.request.InAppShowFailure +import com.google.gson.annotations.SerializedName internal data class InAppFailuresWrapper( - val failures: List + @SerializedName("failures") val failures: List ) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt index de99b37d1..0a1ad6f6c 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/response/InAppConfigResponse.kt @@ -75,7 +75,7 @@ internal data class SettingsDtoBlank( @JsonAdapter(FeatureTogglesDtoBlankDeserializer::class) internal data class FeatureTogglesDtoBlank( - val toggles: Map + @SerializedName("toggles") val toggles: Map ) } @@ -110,14 +110,14 @@ internal data class TtlDto( ) internal data class SlidingExpirationDto( - val config: Milliseconds?, - val pushTokenKeepalive: Milliseconds?, + @SerializedName("config") val config: Milliseconds?, + @SerializedName("pushTokenKeepalive") val pushTokenKeepalive: Milliseconds?, ) internal data class InappSettingsDto( - val maxInappsPerSession: Int?, - val maxInappsPerDay: Int?, - val minIntervalBetweenShows: Milliseconds?, + @SerializedName("maxInappsPerSession") val maxInappsPerSession: Int?, + @SerializedName("maxInappsPerDay") val maxInappsPerDay: Int?, + @SerializedName("minIntervalBetweenShows") val minIntervalBetweenShows: Milliseconds?, ) internal data class LogRequestDto( diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt index 6bcd8d344..2beab7d18 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt @@ -6,6 +6,7 @@ import cloud.mindbox.mobile_sdk.logger.mindboxLogI import cloud.mindbox.mobile_sdk.utils.MindboxUtils.Stopwatch import cloud.mindbox.mobile_sdk.utils.awaitAllWithTimeout import com.google.gson.Gson +import com.google.gson.annotations.SerializedName import com.google.gson.reflect.TypeToken import kotlinx.coroutines.async import kotlinx.coroutines.withContext @@ -18,8 +19,8 @@ internal data class PushToken( ) internal data class PrefPushToken( - val token: String, - val updateDate: Long, + @SerializedName("token") val token: String, + @SerializedName("updateDate") val updateDate: Long, ) internal typealias PushTokenMap = Map From 9b16b106fc436897ddde29f236cb3d59654afde5 Mon Sep 17 00:00:00 2001 From: sozinov Date: Mon, 25 May 2026 14:43:15 +0300 Subject: [PATCH 3/5] MOBILE-178: Auto-detect generic Gson wrappers, drop UnmonitoredGsonWrapper rule --- .../mindbox/detekt/GsonSerializedNameRule.kt | 43 +++-- .../mindbox/detekt/MindboxRuleSetProvider.kt | 1 - .../detekt/UnmonitoredGsonWrapperRule.kt | 61 ------- .../detekt/GsonSerializedNameRuleTest.kt | 39 +++- .../detekt/UnmonitoredGsonWrapperRuleTest.kt | 168 ------------------ detekt.yml | 2 - .../managers/MindboxEventManager.kt | 1 - .../models/operation/CustomFields.kt | 1 - .../mobile_sdk/pushes/MindboxRemoteMessage.kt | 15 +- .../mindbox/mobile_sdk/pushes/PushToken.kt | 2 - 10 files changed, 71 insertions(+), 262 deletions(-) delete mode 100644 detekt-rules/src/main/kotlin/cloud/mindbox/detekt/UnmonitoredGsonWrapperRule.kt delete mode 100644 detekt-rules/src/test/kotlin/cloud/mindbox/detekt/UnmonitoredGsonWrapperRuleTest.kt diff --git a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt index d659e0434..3d5cc9ac7 100644 --- a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt +++ b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt @@ -13,10 +13,13 @@ import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtClassLiteralExpression import org.jetbrains.kotlin.psi.KtDotQualifiedExpression import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtObjectDeclaration import org.jetbrains.kotlin.psi.KtParameter +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType import org.jetbrains.kotlin.resolve.BindingContext import org.jetbrains.kotlin.resolve.DescriptorToSourceUtils +import org.jetbrains.kotlin.resolve.calls.util.getResolvedCall import org.jetbrains.kotlin.resolve.calls.util.getType import org.jetbrains.kotlin.types.KotlinType @@ -45,10 +48,14 @@ class GsonSerializedNameRule(config: Config) : Rule(config) { override fun visitCallExpression(expression: KtCallExpression) { super.visitCallExpression(expression) - if (expression.calleeExpression?.text !in MONITORED_FUNCTION_NAMES) return - checkFirstArgumentType(expression) - checkTypeArguments(expression) - checkClassLiteralArguments(expression) + val calleeName = expression.calleeExpression?.text ?: return + if (calleeName in DIRECT_GSON_FUNCTION_NAMES) { + checkFirstArgumentType(expression) + checkTypeArguments(expression) + checkClassLiteralArguments(expression) + } else { + checkIfIndirectGsonCall(expression) + } } override fun visitObjectDeclaration(declaration: KtObjectDeclaration) { @@ -63,6 +70,21 @@ class GsonSerializedNameRule(config: Config) : Rule(config) { } } + private fun checkIfIndirectGsonCall(expression: KtCallExpression) { + val resolvedCall = expression.getResolvedCall(bindingContext) ?: return + val sourceFunction = DescriptorToSourceUtils + .descriptorToDeclaration(resolvedCall.resultingDescriptor) as? KtNamedFunction ?: return + if (sourceFunction.typeParameters.isEmpty()) return + if (!sourceFunction.containsDirectGsonCall()) return + checkFirstArgumentType(expression) + checkTypeArguments(expression) + checkClassLiteralArguments(expression) + } + + private fun KtNamedFunction.containsDirectGsonCall(): Boolean = + collectDescendantsOfType() + .any { call -> call.calleeExpression?.text in DIRECT_GSON_FUNCTION_NAMES } + private fun checkFirstArgumentType(expression: KtCallExpression) { val argument = expression.valueArguments.firstOrNull()?.getArgumentExpression() ?: return checkKotlinType(argument.getType(bindingContext) ?: return) @@ -84,7 +106,6 @@ class GsonSerializedNameRule(config: Config) : Rule(config) { is KtClassLiteralExpression -> argument else -> null } ?: return@forEach - // KClass or Class — first type argument is T val type = classLiteral.getType(bindingContext) ?: return@forEach type.arguments.firstOrNull() ?.takeIf { projection -> !projection.isStarProjection } @@ -138,19 +159,15 @@ class GsonSerializedNameRule(config: Config) : Rule(config) { } } - internal companion object { + private companion object { private const val SERIALIZED_NAME = "SerializedName" private const val TYPE_TOKEN = "TypeToken" - // To add a new Gson wrapper: just add its name here. - // UnmonitoredGsonWrapperRule references this same set to stay in sync. - internal val MONITORED_FUNCTION_NAMES: Set = setOf( - "convertJsonToBody", + private val DIRECT_GSON_FUNCTION_NAMES: Set = setOf( "fromJson", - "fromJsonTyped", - "operationBodyJson", + "fromJsonTree", "toJson", - "toJsonTyped", + "toJsonTree", ) } } diff --git a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/MindboxRuleSetProvider.kt b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/MindboxRuleSetProvider.kt index 255b5f0dd..1978f0bd9 100644 --- a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/MindboxRuleSetProvider.kt +++ b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/MindboxRuleSetProvider.kt @@ -13,7 +13,6 @@ class MindboxRuleSetProvider : RuleSetProvider { id = ruleSetId, rules = listOf( GsonSerializedNameRule(config = config), - UnmonitoredGsonWrapperRule(config = config), ) ) } diff --git a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/UnmonitoredGsonWrapperRule.kt b/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/UnmonitoredGsonWrapperRule.kt deleted file mode 100644 index 032b2d538..000000000 --- a/detekt-rules/src/main/kotlin/cloud/mindbox/detekt/UnmonitoredGsonWrapperRule.kt +++ /dev/null @@ -1,61 +0,0 @@ -package cloud.mindbox.detekt - -import io.gitlab.arturbosch.detekt.api.CodeSmell -import io.gitlab.arturbosch.detekt.api.Config -import io.gitlab.arturbosch.detekt.api.Debt -import io.gitlab.arturbosch.detekt.api.Entity -import io.gitlab.arturbosch.detekt.api.Issue -import io.gitlab.arturbosch.detekt.api.Rule -import io.gitlab.arturbosch.detekt.api.Severity -import org.jetbrains.kotlin.lexer.KtTokens -import org.jetbrains.kotlin.psi.KtCallExpression -import org.jetbrains.kotlin.psi.KtNamedFunction -import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType - -class UnmonitoredGsonWrapperRule(config: Config) : Rule(config) { - - override val issue: Issue = Issue( - id = "UnmonitoredGsonWrapper", - severity = Severity.Warning, - description = "Function wraps a Gson call but is not monitored by GsonMissingSerializedName. " + - "Add its name to the monitored list in GsonSerializedNameRule.", - debt = Debt.FIVE_MINS - ) - - override fun visitNamedFunction(function: KtNamedFunction) { - super.visitNamedFunction(function) - val name = function.name ?: return - if (function.hasModifier(KtTokens.PRIVATE_KEYWORD)) return - if (function.hasModifier(KtTokens.OVERRIDE_KEYWORD)) return - if (function.typeParameters.isEmpty()) return - if (name in MONITORED_GSON_FUNCTION_NAMES) return - if (!function.containsDirectGsonCall()) return - report( - CodeSmell( - issue = issue, - entity = Entity.atName(function), - message = "'$name' calls Gson internally but is not monitored by GsonMissingSerializedName. " + - "Add '$name' to GsonSerializedNameRule.MONITORED_FUNCTION_NAMES " + - "(detekt-rules/src/main/kotlin/cloud/mindbox/detekt/GsonSerializedNameRule.kt) " + - "so that types passed to '$name' are checked for @SerializedName." - ) - ) - } - - private fun KtNamedFunction.containsDirectGsonCall(): Boolean { - return collectDescendantsOfType() - .any { call -> call.calleeExpression?.text in GSON_BASE_FUNCTION_NAMES } - } - - private companion object { - private val MONITORED_GSON_FUNCTION_NAMES: Set - get() = GsonSerializedNameRule.MONITORED_FUNCTION_NAMES - - private val GSON_BASE_FUNCTION_NAMES: Set = setOf( - "fromJson", - "toJson", - "toJsonTree", - "fromJsonTree", - ) - } -} diff --git a/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/GsonSerializedNameRuleTest.kt b/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/GsonSerializedNameRuleTest.kt index 88bf1fb67..395fbdc30 100644 --- a/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/GsonSerializedNameRuleTest.kt +++ b/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/GsonSerializedNameRuleTest.kt @@ -12,15 +12,15 @@ import org.junit.Test class GsonSerializedNameRuleTest { private val stubs = """ + annotation class SerializedName(val value: String) + abstract class TypeToken fun fromJson(json: String, clazz: Class<*>): Any = error("stub") fun fromJson(json: String, clazz: Class): T = error("stub") fun toJson(obj: Any?): String = error("stub") - fun toJsonTyped(obj: T): String = error("stub") - fun fromJsonTyped(json: String, clazz: Class): T = error("stub") - fun operationBodyJson(obj: T): String = error("stub") - fun convertJsonToBody(json: String, clazz: Class<*>): Any = error("stub") - abstract class TypeToken - annotation class SerializedName(val value: String) + fun fromJsonTyped(json: String, clazz: Class): T = fromJson(json, clazz) + fun toJsonTyped(obj: T): String = toJson(obj) + fun operationBodyJson(obj: T): String = toJson(obj) + fun convertJsonToBody(json: String, clazz: Class): T = fromJson(json, clazz) """.trimIndent() private fun compileAndLint(code: String): List = @@ -118,6 +118,33 @@ class GsonSerializedNameRuleTest { assertTrue(findings.first().message.contains("EventBody.type")) } + @Test + fun `reports class via custom auto-detected generic wrapper`() { + val findings = compileAndLint( + """ + $stubs + data class Payload(val id: String) + fun myDeserializer(json: String, clazz: Class): T = fromJson(json, clazz) + fun test(json: String) = myDeserializer(json, Payload::class.java) + """.trimIndent() + ) + assertEquals(1, findings.size) + assertTrue(findings.first().message.contains("Payload.id")) + } + + @Test + fun `does not report class passed to a function that does not wrap Gson`() { + val findings = compileAndLint( + """ + $stubs + data class Config(val key: String) + fun jacksonDeserialize(json: String, clazz: Class): T = error("stub") + fun test(json: String) = jacksonDeserialize(json, Config::class.java) + """.trimIndent() + ) + assertTrue(findings.isEmpty()) + } + @Test fun `reports class used as direct TypeToken generic argument`() { val findings = compileAndLint( diff --git a/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/UnmonitoredGsonWrapperRuleTest.kt b/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/UnmonitoredGsonWrapperRuleTest.kt deleted file mode 100644 index f78464dcb..000000000 --- a/detekt-rules/src/test/kotlin/cloud/mindbox/detekt/UnmonitoredGsonWrapperRuleTest.kt +++ /dev/null @@ -1,168 +0,0 @@ -package cloud.mindbox.detekt - -import io.gitlab.arturbosch.detekt.test.TestConfig -import io.gitlab.arturbosch.detekt.test.lint -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class UnmonitoredGsonWrapperRuleTest { - - @Test - fun `reports function that wraps fromJson under a new name`() { - val findings = rule.lint( - """ - import com.google.gson.Gson - import com.google.gson.reflect.TypeToken - - fun deserializeModel(json: String, clazz: Class): T = - Gson().fromJson(json, clazz) - """.trimIndent() - ) - assertEquals(1, findings.size) - assertEquals("UnmonitoredGsonWrapper", findings.first().id) - assertTrue(findings.first().message.contains("deserializeModel")) - } - - @Test - fun `reports generic function that wraps toJson under a new name`() { - val findings = rule.lint( - """ - import com.google.gson.Gson - - fun serializePayload(payload: T): String = Gson().toJson(payload) - """.trimIndent() - ) - assertEquals(1, findings.size) - assertTrue(findings.first().message.contains("serializePayload")) - } - - @Test - fun `does not report non-generic function wrapping Gson`() { - val findings = rule.lint( - """ - import com.google.gson.Gson - - data class Config(val key: String) - fun serializeConfig(config: Config): String = Gson().toJson(config) - """.trimIndent() - ) - // Non-generic: explicit type Config is already detected by GsonMissingSerializedName. - assertTrue(findings.isEmpty()) - } - - @Test - fun `does not report function already monitored by GsonMissingSerializedName`() { - val findings = rule.lint( - """ - import com.google.gson.Gson - - fun toJson(obj: T): String = Gson().toJson(obj) - fun fromJson(json: String, clazz: Class): T = Gson().fromJson(json, clazz) - fun toJsonTyped(obj: T): String = Gson().toJson(obj) - fun fromJsonTyped(json: String, clazz: Class): T = Gson().fromJson(json, clazz) - fun operationBodyJson(obj: T): String = Gson().toJson(obj) - fun convertJsonToBody(json: String, clazz: Class): T = Gson().fromJson(json, clazz) - """.trimIndent() - ) - assertTrue(findings.isEmpty()) - } - - @Test - fun `reports internal generic function wrapping Gson`() { - val findings = rule.lint( - """ - import com.google.gson.Gson - - internal fun loadModel(json: String, clazz: Class): T = Gson().fromJson(json, clazz) - """.trimIndent() - ) - assertEquals(1, findings.size) - assertTrue(findings.first().message.contains("loadModel")) - } - - @Test - fun `does not report private function wrapping Gson`() { - val findings = rule.lint( - """ - import com.google.gson.Gson - - class Repo { - private fun loadInternal(json: String): Any = Gson().fromJson(json, Any::class.java) - } - """.trimIndent() - ) - assertTrue(findings.isEmpty()) - } - - @Test - fun `does not report override function wrapping Gson`() { - val findings = rule.lint( - """ - import com.google.gson.Gson - - class GeoSerializer : GeoSerializationManager { - override fun deserializeToGeoTargeting(json: String): Any = - Gson().fromJson(json, Any::class.java) - } - """.trimIndent() - ) - assertTrue(findings.isEmpty()) - } - - @Test - fun `does not report function that does not call Gson`() { - val findings = rule.lint( - """ - import com.fasterxml.jackson.databind.ObjectMapper - - fun deserializeWithJackson(json: String): Any = - ObjectMapper().readValue(json, Any::class.java) - """.trimIndent() - ) - assertTrue(findings.isEmpty()) - } - - @Test - fun `reports generic extension function on Gson receiver`() { - val findings = rule.lint( - """ - import com.google.gson.Gson - - fun Gson.parseConfig(json: String, clazz: Class): T = fromJson(json, clazz) - """.trimIndent() - ) - assertEquals(1, findings.size) - assertTrue(findings.first().message.contains("parseConfig")) - } - - @Test - fun `does not report non-generic extension function on Gson receiver`() { - val findings = rule.lint( - """ - import com.google.gson.Gson - - data class Config(val key: String) - fun Gson.parseConfig(json: String): Config = fromJson(json, Config::class.java) - """.trimIndent() - ) - assertTrue(findings.isEmpty()) - } - - @Test - fun `reports outer generic function when Gson call is inside a nested lambda`() { - val findings = rule.lint( - """ - import com.google.gson.Gson - - fun buildDeserializer(clazz: Class): () -> T = { - Gson().fromJson("{}", clazz) - } - """.trimIndent() - ) - assertEquals(1, findings.size) - assertTrue(findings.first().message.contains("buildDeserializer")) - } - - private val rule = UnmonitoredGsonWrapperRule(config = TestConfig()) -} diff --git a/detekt.yml b/detekt.yml index d0fce63e2..81724dec1 100644 --- a/detekt.yml +++ b/detekt.yml @@ -33,5 +33,3 @@ mindbox: active: true GsonMissingSerializedName: active: true - UnmonitoredGsonWrapper: - active: true diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt index 93aed8336..7f2120877 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/managers/MindboxEventManager.kt @@ -162,7 +162,6 @@ internal object MindboxEventManager { } } - @Suppress("UnmonitoredGsonWrapper") // body: T is a client subclass of OperationBodyRequestBase — outside SDK control. fun syncOperation( name: String, body: T, diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/CustomFields.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/CustomFields.kt index 35f77d0d6..9cdafecb6 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/CustomFields.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/models/operation/CustomFields.kt @@ -18,7 +18,6 @@ public class CustomFields(public val fields: Map? = null) { * properties of [T] must declare [@SerializedName][com.google.gson.annotations.SerializedName] * to ensure correct serialization after code shrinking (ProGuard/R8). */ - @Suppress("UnmonitoredGsonWrapper") // T is a caller-supplied type; SDK cannot enforce @SerializedName on it. public fun convertTo(classOfT: Class): T? = LoggingExceptionHandler.runCatching(defaultValue = null) { val gson = Gson() gson.fromJson(gson.toJson(fields), classOfT) diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/MindboxRemoteMessage.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/MindboxRemoteMessage.kt index b85d0d477..9083fad81 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/MindboxRemoteMessage.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/MindboxRemoteMessage.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.app.PendingIntent import android.content.Context import android.os.Bundle +import com.google.gson.annotations.SerializedName /** * A class representing mindbox remote message @@ -11,13 +12,13 @@ import android.os.Bundle * with your custom push notification implementation. * */ public data class MindboxRemoteMessage( - val uniqueKey: String, - val title: String, - val description: String, - val pushActions: List, - val pushLink: String?, - val imageUrl: String?, - val payload: String?, + @SerializedName("uniqueKey") val uniqueKey: String, + @SerializedName("title") val title: String, + @SerializedName("description") val description: String, + @SerializedName("pushActions") val pushActions: List, + @SerializedName("pushLink") val pushLink: String?, + @SerializedName("imageUrl") val imageUrl: String?, + @SerializedName("payload") val payload: String?, ) { public companion object { public const val DATA_UNIQUE_KEY: String = "uniqueKey" diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt index 2beab7d18..0ce645111 100644 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/pushes/PushToken.kt @@ -26,8 +26,6 @@ internal data class PrefPushToken( internal typealias PushTokenMap = Map internal typealias PrefPushTokenMap = Map -// Called only with Map and Map — both covered by GsonMissingSerializedName. -@Suppress("UnmonitoredGsonWrapper") internal fun Map.toPreferences(): String = runCatching { Gson().toJson(this) From b98093f3edbe79d6f99c83470339d8820a520767 Mon Sep 17 00:00:00 2001 From: sozinov Date: Mon, 25 May 2026 15:54:40 +0300 Subject: [PATCH 4/5] MOBILE-178: test --- .../mindbox/mobile_sdk/DetektSmokeTest.kt | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/DetektSmokeTest.kt diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/DetektSmokeTest.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/DetektSmokeTest.kt new file mode 100644 index 000000000..b89ffafef --- /dev/null +++ b/sdk/src/main/java/cloud/mindbox/mobile_sdk/DetektSmokeTest.kt @@ -0,0 +1,65 @@ +package cloud.mindbox.mobile_sdk + +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import com.google.gson.reflect.TypeToken + +// Temporary file to verify detekt rules catch violations. Delete after CI check. + +// GsonMissingSerializedName: caught via fromJson with class literal (smokeFromJson below) +internal data class SmokeTestDto( + val id: String, + val name: String, +) + +// GsonMissingSerializedName: caught via toJson first-arg type (smokeToJson below) +// Also caught transitively: SmokeTestNestedDto.dto is SmokeTestDto → recurses into SmokeTestDto +internal data class SmokeTestNestedDto( + val value: Int, + val dto: SmokeTestDto, +) + +// GsonMissingSerializedName: caught via visitClass — partial @SerializedName contract +// (some fields annotated, some not → always flagged, even without a call site) +internal data class SmokeTestPartialDto( + @SerializedName("title") val title: String, + val subtitle: String, +) + +// GsonMissingSerializedName: caught via TypeToken type argument (smokeTypeToken below) +internal data class SmokeTestTypeTokenDto( + val key: String, + val payload: String, +) + +// GsonMissingSerializedName: caught via TypeToken> — item type extracted from List argument +internal data class SmokeTestListItemDto( + val item: String, +) + +// GsonMissingSerializedName: caught via auto-detected generic wrapper (checkIfIndirectGsonCall) +// smokeGenericWrapper is generic + contains Gson().toJson() → rule resolves its source and flags callers +internal data class SmokeTestWrapperDto( + val x: Int, +) + +// --- Call sites that trigger GsonMissingSerializedName --- + +// fromJson with class literal → catches SmokeTestDto +private fun smokeFromJson(json: String): SmokeTestDto = + Gson().fromJson(json, SmokeTestDto::class.java) + +// toJson with first-arg type resolution → catches SmokeTestNestedDto + transitive SmokeTestDto +private fun smokeToJson(dto: SmokeTestNestedDto): String = + Gson().toJson(dto) + +// TypeToken direct type argument → catches SmokeTestTypeTokenDto +private val smokeTypeToken = object : TypeToken() {} + +// TypeToken with List → extracts SmokeTestListItemDto from the List argument +private val smokeListToken = object : TypeToken>() {} + +// Generic wrapper auto-detection → catches SmokeTestWrapperDto +private fun smokeGenericWrapper(obj: T): String = Gson().toJson(obj) + +private val smokeWrapped = smokeGenericWrapper(SmokeTestWrapperDto(1)) From 66e52fcff7d0db5584f14106a0a723939ec0db2c Mon Sep 17 00:00:00 2001 From: sozinov Date: Mon, 25 May 2026 16:18:08 +0300 Subject: [PATCH 5/5] MOBILE-178: remove test --- .../mindbox/mobile_sdk/DetektSmokeTest.kt | 65 ------------------- 1 file changed, 65 deletions(-) delete mode 100644 sdk/src/main/java/cloud/mindbox/mobile_sdk/DetektSmokeTest.kt diff --git a/sdk/src/main/java/cloud/mindbox/mobile_sdk/DetektSmokeTest.kt b/sdk/src/main/java/cloud/mindbox/mobile_sdk/DetektSmokeTest.kt deleted file mode 100644 index b89ffafef..000000000 --- a/sdk/src/main/java/cloud/mindbox/mobile_sdk/DetektSmokeTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package cloud.mindbox.mobile_sdk - -import com.google.gson.Gson -import com.google.gson.annotations.SerializedName -import com.google.gson.reflect.TypeToken - -// Temporary file to verify detekt rules catch violations. Delete after CI check. - -// GsonMissingSerializedName: caught via fromJson with class literal (smokeFromJson below) -internal data class SmokeTestDto( - val id: String, - val name: String, -) - -// GsonMissingSerializedName: caught via toJson first-arg type (smokeToJson below) -// Also caught transitively: SmokeTestNestedDto.dto is SmokeTestDto → recurses into SmokeTestDto -internal data class SmokeTestNestedDto( - val value: Int, - val dto: SmokeTestDto, -) - -// GsonMissingSerializedName: caught via visitClass — partial @SerializedName contract -// (some fields annotated, some not → always flagged, even without a call site) -internal data class SmokeTestPartialDto( - @SerializedName("title") val title: String, - val subtitle: String, -) - -// GsonMissingSerializedName: caught via TypeToken type argument (smokeTypeToken below) -internal data class SmokeTestTypeTokenDto( - val key: String, - val payload: String, -) - -// GsonMissingSerializedName: caught via TypeToken> — item type extracted from List argument -internal data class SmokeTestListItemDto( - val item: String, -) - -// GsonMissingSerializedName: caught via auto-detected generic wrapper (checkIfIndirectGsonCall) -// smokeGenericWrapper is generic + contains Gson().toJson() → rule resolves its source and flags callers -internal data class SmokeTestWrapperDto( - val x: Int, -) - -// --- Call sites that trigger GsonMissingSerializedName --- - -// fromJson with class literal → catches SmokeTestDto -private fun smokeFromJson(json: String): SmokeTestDto = - Gson().fromJson(json, SmokeTestDto::class.java) - -// toJson with first-arg type resolution → catches SmokeTestNestedDto + transitive SmokeTestDto -private fun smokeToJson(dto: SmokeTestNestedDto): String = - Gson().toJson(dto) - -// TypeToken direct type argument → catches SmokeTestTypeTokenDto -private val smokeTypeToken = object : TypeToken() {} - -// TypeToken with List → extracts SmokeTestListItemDto from the List argument -private val smokeListToken = object : TypeToken>() {} - -// Generic wrapper auto-detection → catches SmokeTestWrapperDto -private fun smokeGenericWrapper(obj: T): String = Gson().toJson(obj) - -private val smokeWrapped = smokeGenericWrapper(SmokeTestWrapperDto(1))