diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/AnalyticsOptions.kt b/app-sizer/src/main/kotlin/com/grab/sizer/AnalyticsOptions.kt index 71a7649..bfd9909 100644 --- a/app-sizer/src/main/kotlin/com/grab/sizer/AnalyticsOptions.kt +++ b/app-sizer/src/main/kotlin/com/grab/sizer/AnalyticsOptions.kt @@ -36,7 +36,7 @@ import java.io.Serializable * - APK: Generates a report breaking down the App Download Size by Components. Sections include android-java-libraries, codebase-kotlin-java, codebase-resources, codebase-assets, codebase-native, and native libraries. * - BASIC: Provides a fundamental breakdown report of the App Download size, similar to opening the APK in Android Studio. * - MODULES: Generates a report showing the contribution of each module to the total App Download Size. Grouping by team should be feasible. - * - CODEBASE: Produces a report showing the contribution of each team to the total App Download Size. + * - TEAMS: Produces a report showing the contribution of each team to the total App Download Size. * - LARGE_FILE: Generates a list of files whose sizes exceed a certain threshold. * - LIB_CONTENT: Provides a breakdown of a single library's size contribution (Resources, Assets, Native libraries, Classes, others). */ @@ -46,7 +46,7 @@ enum class AnalyticsOption : Serializable { APK, BASIC, MODULES, - CODEBASE, + TEAMS, LARGE_FILE, LIB_CONTENT; @@ -60,7 +60,7 @@ enum class AnalyticsOption : Serializable { "modules" -> MODULES "apk" -> APK "basic" -> BASIC - "codebase" -> CODEBASE + "teams" -> TEAMS "large-files" -> LARGE_FILE "lib-content" -> LIB_CONTENT else -> DEFAULT diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/ApkAnalyzer.kt b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/ApkAnalyzer.kt index 0293f89..fa7d277 100644 --- a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/ApkAnalyzer.kt +++ b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/ApkAnalyzer.kt @@ -34,6 +34,7 @@ import com.grab.sizer.parser.DataParser import com.grab.sizer.report.Report import com.grab.sizer.report.Row import com.grab.sizer.report.apksSizeReport +import com.grab.sizer.report.size import com.grab.sizer.report.toReportField import java.io.File import javax.inject.Inject @@ -77,7 +78,7 @@ internal class ApkAnalyzer @Inject constructor( val listOfReport = listOf(apkReportRow) + codeBaseReports + libComponentReport return Report( - rows = listOfReport, + rows = listOfReport.sortedBy { it.size() }, id = METRICS_ID_APK, name = METRICS_ID_APK, ) @@ -119,34 +120,34 @@ internal class ApkAnalyzer @Inject constructor( private fun libComponentReport(allLibReport: ReportItem): List = listOf( createRow( - name = "android-java-libraries", + name = ANDROID_JAVA_LIBRARIES_ID, value = allLibReport.totalDownloadSize - allLibReport.nativeLibDownloadSize ), createRow( - name = "native-libraries", + name = NATIVE_LIBRARIES_ID, value = allLibReport.nativeLibDownloadSize ) ) private fun codeBaseComponentReport(codeBaseReport: ReportItem): List = listOf( createRow( - name = "codebase-kotlin-java", + name = CODEBASE_KOTLIN_JAVA_ID, value = codeBaseReport.classesDownloadSize, ), createRow( - name = "codebase-resources", + name = CODEBASE_RESOURCES_ID, value = codeBaseReport.resourceDownloadSize, ), createRow( - name = "codebase-assets", + name = CODEBASE_ASSETS_ID, value = codeBaseReport.assetDownloadSize, ), createRow( - name = "codebase-native", + name = CODEBASE_NATIVE_ID, value = codeBaseReport.nativeLibDownloadSize, ), createRow( - name = "others", + name = OTHERS_ID, value = codeBaseReport.otherDownloadSize, ), ) diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/BasicAnalyzer.kt b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/BasicAnalyzer.kt index d4aec25..df39880 100644 --- a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/BasicAnalyzer.kt +++ b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/BasicAnalyzer.kt @@ -31,6 +31,7 @@ import com.grab.sizer.parser.ApkFileInfo import com.grab.sizer.parser.DataParser import com.grab.sizer.report.Report import com.grab.sizer.report.Row +import com.grab.sizer.report.size import javax.inject.Inject /** @@ -51,7 +52,7 @@ internal class BasicApkAnalyzer @Inject constructor( override fun process(): Report { val androidBinaryInfo = dataParser.apks return Report( - rows = androidBinaryInfo.createApkReportRows(), + rows = androidBinaryInfo.createApkReportRows().sortedBy { it.size() }, id = METRICS_ID_BASIC, name = METRICS_ID_BASIC, ) diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/LargeFileAnalyzer.kt b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/LargeFileAnalyzer.kt index c3d51f9..8e9df68 100644 --- a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/LargeFileAnalyzer.kt +++ b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/LargeFileAnalyzer.kt @@ -34,6 +34,7 @@ import com.grab.sizer.parser.getAars import com.grab.sizer.parser.getJars import com.grab.sizer.report.Report import com.grab.sizer.report.Row +import com.grab.sizer.report.size import javax.inject.Inject import javax.inject.Named @@ -51,7 +52,7 @@ import javax.inject.Named internal class LargeFileAnalyzer @Inject constructor( private val apkComponentProcessor: ApkComponentProcessor, private val dataParser: DataParser, - private val teamMapping: TeamMapping, + private val teamMapping: TeamMapping?, @Named("largeFileThreshold") private val largeFileThreshold: Long ) : Analyzer { @@ -105,7 +106,7 @@ internal class LargeFileAnalyzer @Inject constructor( return Report( id = METRICS_ID_LARGE_FILES, name = METRICS_ID_LARGE_FILES, - rows = reportRows, + rows = reportRows.sortedBy { it.size() }, ) } @@ -113,20 +114,18 @@ internal class LargeFileAnalyzer @Inject constructor( it.resourcesDownloadSize + it.assetsDownloadSize } - private fun List.toReportRows(): List = map { it to it.modules } - .flatMap { pair -> - pair.second.flatMap { module -> - module.contributors.flatMap { contributor -> contributor.resources + contributor.assets } - .map { res -> - val segmentPaths = res.path.split("/") - val fileName = segmentPaths.last() - createRow( - name = fileName, - value = res.downloadSize, - owner = pair.first.name, - tag = module.tag, - rowName = fileName - ) + private fun List.toReportRows(): List = flatMap { team -> + team.contributors.flatMap { contributor -> + (contributor.resources + contributor.assets).map { res -> + val segmentPaths = res.path.split("/") + val fileName = segmentPaths.last() + createRow( + name = fileName, + value = res.downloadSize, + owner = team.name, + tag = contributor.tag, + rowName = fileName + ) } } } diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/LibContentAnalyzer.kt b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/LibContentAnalyzer.kt index 312fe75..a1431c9 100644 --- a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/LibContentAnalyzer.kt +++ b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/LibContentAnalyzer.kt @@ -31,10 +31,10 @@ import com.grab.sizer.analyzer.mapper.ApkComponentProcessor import com.grab.sizer.analyzer.model.Contributor import com.grab.sizer.analyzer.model.FileInfo import com.grab.sizer.di.NAMED_LIB_NAME -import com.grab.sizer.parser.ApkFileInfo import com.grab.sizer.parser.DataParser import com.grab.sizer.report.Report import com.grab.sizer.report.Row +import com.grab.sizer.report.size import java.io.File import javax.inject.Inject import javax.inject.Named @@ -74,7 +74,7 @@ internal class LibContentAnalyzer @Inject constructor( return Report( id = LIB_CONTENT_METRICS_ID, name = LIB_CONTENT_METRICS_ID, - rows = resourceRows + assetRows + nativeLibRows + otherRows + classRows, + rows = (resourceRows + assetRows + nativeLibRows + otherRows + classRows).sortedBy { it.size() }, ) } diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/LibrariesAnalyzer.kt b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/LibrariesAnalyzer.kt index e0a2260..5bdd660 100644 --- a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/LibrariesAnalyzer.kt +++ b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/LibrariesAnalyzer.kt @@ -29,9 +29,9 @@ package com.grab.sizer.analyzer import com.grab.sizer.analyzer.mapper.ApkComponentProcessor import com.grab.sizer.analyzer.model.Contributor -import com.grab.sizer.parser.ApkFileInfo import com.grab.sizer.parser.DataParser import com.grab.sizer.report.Report +import com.grab.sizer.report.size import java.io.File import javax.inject.Inject @@ -43,10 +43,12 @@ import javax.inject.Inject * * @property apkComponentProcessor An instance for processing APK, AAR, or JAR files to produce a list of contributors. * @property dataParser Parses APK, AAR, and JAR files for analysis. + * @property teamMapping Optional team ownership mapping for libraries. */ internal class LibrariesAnalyzer @Inject constructor( private val apkComponentProcessor: ApkComponentProcessor, - private val dataParser: DataParser + private val dataParser: DataParser, + private val teamMapping: TeamMapping? ) : Analyzer { override fun process(): Report { val processedData = apkComponentProcessor.process( @@ -63,7 +65,13 @@ internal class LibrariesAnalyzer @Inject constructor( return Report( id = LIBRARY_METRICS_ID, name = LIBRARY_METRICS_ID, - rows = listOfReport.map { reportItem -> createRow(reportItem.name, reportItem.totalDownloadSize) }, + rows = listOfReport.map { reportItem -> + createRow( + name = reportItem.name, + value = reportItem.totalDownloadSize, + owner = reportItem.owner + ) + }.sortedBy { it.size() }, ) } @@ -71,6 +79,7 @@ internal class LibrariesAnalyzer @Inject constructor( name = tag, extraInfo = path.substring(path.indexOf("files-2.1/") + 9), id = File(path).nameWithoutExtension, + owner = teamMapping?.getLibraryOwner(tag), totalDownloadSize = getDownloadSize(), classesDownloadSize = classDownloadSize, nativeLibDownloadSize = nativeLibDownloadSize, diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/MetricIds.kt b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/MetricIds.kt index 05a9ef8..f425238 100644 --- a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/MetricIds.kt +++ b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/MetricIds.kt @@ -32,12 +32,21 @@ import com.grab.sizer.report.* internal const val LIBRARY_METRICS_ID = "library" internal const val METRICS_ID_APK = "apk" internal const val METRICS_ID_BASIC = "apk_basic" -internal const val METRICS_ID_CODEBASE = "team" +internal const val METRICS_ID_TEAM = "team" internal const val METRICS_ID_LARGE_FILES = "large_file" internal const val LIB_CONTENT_METRICS_ID = "library_content" internal const val METRICS_ID_MODULES = "module" internal const val NOT_AVAILABLE_VALUE = "NA" +internal const val CODEBASE_KOTLIN_JAVA_ID = "codebase-kotlin-java" +internal const val CODEBASE_RESOURCES_ID = "codebase-resources" +internal const val CODEBASE_ASSETS_ID = "codebase-assets" +internal const val CODEBASE_NATIVE_ID = "codebase-native" +internal const val OTHERS_ID = "others" +internal const val TOTAL_ID = "total" +internal const val ANDROID_JAVA_LIBRARIES_ID = "android-java-libraries" +internal const val NATIVE_LIBRARIES_ID = "native-libraries" + internal fun createRow( name: String, value: Long, diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/ModuleAnalyzer.kt b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/ModuleAnalyzer.kt index f1b436c..eac743f 100644 --- a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/ModuleAnalyzer.kt +++ b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/ModuleAnalyzer.kt @@ -33,6 +33,7 @@ import com.grab.sizer.parser.DataParser import com.grab.sizer.parser.getAars import com.grab.sizer.parser.getJars import com.grab.sizer.report.Report +import com.grab.sizer.report.size import javax.inject.Inject @@ -49,7 +50,7 @@ import javax.inject.Inject internal class ModuleAnalyzer @Inject constructor( private val apkComponentProcessor: ApkComponentProcessor, private val dataParser: DataParser, - private val teamMapping: TeamMapping, + private val teamMapping: TeamMapping?, ) : Analyzer { override fun process(): Report { /** @@ -80,11 +81,11 @@ internal class ModuleAnalyzer @Inject constructor( private fun generateReport(contributors: Set): Report = contributors.toModules() .run { val sortedTeamsReport = sortedBy { it.getDownloadSize() } - .map { it.toReportItem(teamMapping.moduleToTeamMap) } + .map { it.toReportItem(teamMapping) } return Report( id = METRICS_ID_MODULES, name = METRICS_ID_MODULES, - rows = toReportRows(sortedTeamsReport), + rows = toReportRows(sortedTeamsReport).sortedBy { it.size() }, ) } diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/CodebaseAnalyzer.kt b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/TeamAnalyzer.kt similarity index 51% rename from app-sizer/src/main/kotlin/com/grab/sizer/analyzer/CodebaseAnalyzer.kt rename to app-sizer/src/main/kotlin/com/grab/sizer/analyzer/TeamAnalyzer.kt index 2aeee68..cbb5da4 100644 --- a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/CodebaseAnalyzer.kt +++ b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/TeamAnalyzer.kt @@ -39,18 +39,18 @@ import javax.inject.Inject /** - * A specific implementation of the Analyzer interface with a focus on project codebase analysis. - * Assigned to handle [com.grab.sizer.AnalyticsOption.CODEBASE], this class provides a detailed report on the - * size contributions of individual team to the total app download size. + * Team-focused analyzer that provides comprehensive team dashboard analysis. + * Assigned to handle [com.grab.sizer.AnalyticsOption.TEAMS], this class provides a detailed report on the + * total size contributions of individual teams to the app download size, combining both module and library contributions. * * @property dataParser Handles the parsing of APK, AAR, or JAR files. * @property apkComponentProcessor Processes APK, AAR, or JAR files to produce a list of contributors. - * @property teamMapping Maps module to their corresponding team and vise versa + * @property teamMapping Optional team mapping for modules and libraries. */ -internal class CodebaseAnalyzer @Inject constructor( +internal class TeamAnalyzer @Inject constructor( private val dataParser: DataParser, private val apkComponentProcessor: ApkComponentProcessor, - private val teamMapping: TeamMapping, + private val teamMapping: TeamMapping?, ) : Analyzer { override fun process(): Report { /** @@ -72,29 +72,89 @@ internal class CodebaseAnalyzer @Inject constructor( //others = wholeProject.noOwnerOthers.castToRawFile() ) - val modulesData = apkComponentProcessor + // Process all contributors together (modules + libraries) for team analysis + val allContributorsData = apkComponentProcessor .process( dataParser.apks, - dataParser.moduleAars, - dataParser.moduleJars + dataParser.getAars(), + dataParser.getJars() ) - return generateReport(modulesData.contributors + appContributor) + + return generateTeamReport(allContributorsData.contributors + appContributor) } - private fun generateReport(contributors: Set): Report { + private fun generateTeamReport(contributors: Set): Report { + // Convert all contributors to teams (handles both modules and libraries) val teams: List = contributors.toTeams(teamMapping) - val sortedTeamsReport = teams.sort() - .map { it.toReportRow() } + + val allTeamRows = teams.sortedBy { it.getDownloadSize() } + .flatMap { it.toDetailedReportRows() } return Report( - id = METRICS_ID_CODEBASE, - name = METRICS_ID_CODEBASE, - rows = sortedTeamsReport + id = METRICS_ID_TEAM, + name = METRICS_ID_TEAM, + rows = allTeamRows ) } - private fun Team.toReportRow(): Row = createRow( - name, - getDownloadSize(), + private fun Team.toDetailedReportRows(): List { + return teamTotalReport() + teamLibrariesReport() + teamComponentReport() + } + + private fun Team.teamTotalReport(): List = listOf( + createRow( + name = TOTAL_ID, + value = getDownloadSize(), + owner = name, + rowName = name + ) + ) + + private fun Team.teamLibrariesReport(): List = listOf( + createRow( + name = ANDROID_JAVA_LIBRARIES_ID, + value = libTotalDownloadSize - libNativeDownloadSize, + owner = name, + rowName = name + ), + createRow( + name = NATIVE_LIBRARIES_ID, + value = libNativeDownloadSize, + owner = name, + rowName = name + ) + ) + + private fun Team.teamComponentReport(): List = listOf( + createRow( + name = CODEBASE_KOTLIN_JAVA_ID, + value = codebaseClassDownloadSize, + owner = name, + rowName = name + ), + createRow( + name = CODEBASE_RESOURCES_ID, + value = codebaseResourcesDownloadSize, + owner = name, + rowName = name + ), + createRow( + name = CODEBASE_ASSETS_ID, + value = codebaseAssetsDownloadSize, + owner = name, + rowName = name + ), + createRow( + name = CODEBASE_NATIVE_ID, + value = codebaseNativeLibDownloadSize, + owner = name, + rowName = name + ), + createRow( + name = OTHERS_ID, + value = codebaseOthersDownloadSize, + owner = name, + rowName = name + ) ) } diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/TeamMapping.kt b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/TeamMapping.kt index cc73f69..60361e3 100644 --- a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/TeamMapping.kt +++ b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/TeamMapping.kt @@ -26,34 +26,64 @@ */ package com.grab.sizer.analyzer -import java.io.File +import com.grab.sizer.analyzer.validation.TeamValidator +import com.grab.sizer.analyzer.validation.ValidationReporter +import com.grab.sizer.utils.Logger +import java.io.File /** - * An interface that represents a bi-directional mapping between modules and teams. - * It allows the retrieval of the associated team for a given module and vice versa. + * An interface that represents a bi-directional mapping between modules and teams, + * with support for library ownership mapping using Maven coordinates. + * It allows the retrieval of the associated team for a given module or library. + * + * Also serves as a data provider for validation operations. */ interface TeamMapping { - val teamToModuleMap: Map> - val moduleToTeamMap: Map -} + // Core ownership lookup methods + fun getModuleOwner(moduleName: String): String? + fun getLibraryOwner(libraryCoordinate: String): String? + fun getAllTeams(): Set -class DummyTeamMapping : TeamMapping { - override val teamToModuleMap: Map> = emptyMap() - override val moduleToTeamMap: Map = emptyMap() -} + fun getModuleTeams(): Set + fun getLibraryTeams(): Set + companion object { + /** + * Factory method that creates a TeamMapping with automatic validation and reporting. + * Encapsulates the orchestration of data loading, validation, and reporting. + */ + fun createWithValidation( + moduleFile: File, + libraryFile: File? = null, + logger: Logger + ): TeamMapping = YmlTeamMapping(moduleFile, libraryFile).apply { + ValidationReporter(logger).reportValidationResult( + TeamValidator().validateTeamMapping(this) + ) + } + + } +} +/** + * Pure data provider implementation of TeamMapping. + * Focused solely on loading and providing team data from YAML files. + * No validation concerns - that's handled by TeamValidator. + */ class YmlTeamMapping( - private val ymlFile: File + private val moduleFile: File, + private val libraryFile: File? = null ) : TeamMapping { - override val teamToModuleMap: Map> by lazy { - loadTeamToModuleMap() + + private val teamToModuleMap: Map> by lazy { + loadTeamToModuleMap(moduleFile) .mapValues { entry -> entry.value.map { it.trim(':') } } } - override val moduleToTeamMap: Map by lazy { + + private val moduleToTeamMap: Map by lazy { mutableMapOf().apply { teamToModuleMap.forEach { (team, modules) -> modules.forEach { put(it, team) } @@ -61,8 +91,55 @@ class YmlTeamMapping( } } - private fun loadTeamToModuleMap(): Map> { - val lines = ymlFile.readLines() + private val teamToLibraryMap: Map> by lazy { + if (libraryFile?.exists() == true) loadTeamToModuleMap(libraryFile) + else emptyMap() + } + + override fun getModuleOwner(moduleName: String): String? { + return moduleToTeamMap[moduleName] + } + + override fun getLibraryOwner(libraryCoordinate: String): String? { + return findBestPatternMatch(libraryCoordinate, teamToLibraryMap) + } + + override fun getAllTeams(): Set { + return (teamToModuleMap.keys + teamToLibraryMap.keys).toSet() + } + + override fun getModuleTeams(): Set { + return teamToModuleMap.keys + } + + override fun getLibraryTeams(): Set { + return teamToLibraryMap.keys + } + + + private fun findBestPatternMatch(coordinate: String, teamMapping: Map>): String? { + // Priority: exact > artifact wildcard > group wildcard + for ((team, patterns) in teamMapping) { + // Check exact match first + if (coordinate in patterns) return team + + // Check artifact wildcards (com.example:library:*) + patterns.filter { it.contains(":*") }.forEach { pattern -> + val prefix = pattern.substringBeforeLast(":*") + if (coordinate.startsWith(prefix)) return team + } + + // Check group wildcards (com.example.*) + patterns.filter { it.endsWith(".*") }.forEach { pattern -> + val prefix = pattern.substringBeforeLast(".*") + if (coordinate.startsWith(prefix)) return team + } + } + return null + } + + private fun loadTeamToModuleMap(file: File): Map> { + val lines = file.readLines() val result = mutableMapOf>() var currentTeam: String? = null @@ -74,6 +151,7 @@ class YmlTeamMapping( currentTeam = trimmedLine.dropLast(1) result[currentTeam] = mutableListOf() } + trimmedLine.startsWith("- ") -> { currentTeam?.let { result[it]?.add(trimmedLine.removePrefix("- ").trim()) @@ -84,5 +162,4 @@ class YmlTeamMapping( return result } - } diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/model/Team.kt b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/model/Team.kt index 68ca52a..e69bcb0 100644 --- a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/model/Team.kt +++ b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/model/Team.kt @@ -33,14 +33,29 @@ import com.grab.sizer.parser.BinaryFileInfo data class Team( val name: String, - val modules: List + val moduleContributors: List, + val libContributors: List ) { - val resourcesDownloadSize: Long by lazy { modules.sumOf { contributor -> contributor.resourcesDownloadSize } } - val nativeLibDownloadSize: Long by lazy { modules.sumOf { contributor -> contributor.nativeLibDownloadSize } } - val assetsDownloadSize: Long by lazy { modules.sumOf { contributor -> contributor.assetsDownloadSize } } - val othersDownloadSize: Long by lazy { modules.sumOf { contributor -> contributor.othersDownloadSize } } - val classDownloadSize: Long by lazy { modules.sumOf { contributor -> contributor.classDownloadSize } } - fun getDownloadSize(): Long = modules.sumOf { it.getDownloadSize() } + + val contributors: List = moduleContributors + libContributors + + fun getDownloadSize(): Long = contributors.sumOf { it.getDownloadSize() } + + val codebaseResourcesDownloadSize: Long by lazy { moduleContributors.sumOf { it.resourcesDownloadSize } } + val codebaseNativeLibDownloadSize: Long by lazy { moduleContributors.sumOf { it.nativeLibDownloadSize } } + val codebaseAssetsDownloadSize: Long by lazy { moduleContributors.sumOf { it.assetsDownloadSize } } + val codebaseOthersDownloadSize: Long by lazy { moduleContributors.sumOf { it.othersDownloadSize } } + val codebaseClassDownloadSize: Long by lazy { moduleContributors.sumOf { it.classDownloadSize } } + + // Library-specific calculations for library reports + val libTotalDownloadSize: Long by lazy { libContributors.sumOf { it.getDownloadSize() } } + val libNativeDownloadSize: Long by lazy { libContributors.sumOf { it.nativeLibDownloadSize } } + + val resourcesDownloadSize: Long by lazy { contributors.sumOf { it.resourcesDownloadSize } } + val nativeLibDownloadSize: Long by lazy { contributors.sumOf { it.nativeLibDownloadSize } } + val assetsDownloadSize: Long by lazy { contributors.sumOf { it.assetsDownloadSize } } + val othersDownloadSize: Long by lazy { contributors.sumOf { it.othersDownloadSize } } + val classDownloadSize: Long by lazy { contributors.sumOf { it.classDownloadSize } } } data class Module( @@ -61,13 +76,32 @@ data class Module( resourcesDownloadSize + nativeLibDownloadSize + assetsDownloadSize + othersDownloadSize + classDownloadSize } -internal fun Set.toTeams(teamMapping: TeamMapping): List { - val modules = toModules() - return teamMapping.teamToModuleMap.mapValues { teamToModule -> - teamToModule.value.mapNotNull { moduleNameFromTeamMapping -> - modules.find { module -> module.tag == moduleNameFromTeamMapping } +internal fun Set.toTeams(teamMapping: TeamMapping?): List { + return teamMapping?.let { mapping -> + // Single-pass grouping to avoid O(N×T) complexity + val moduleContributorsByTeam = mutableMapOf>() + val libContributorsByTeam = mutableMapOf>() + + // Group contributors by team in single pass + this.forEach { contributor -> + mapping.getModuleOwner(contributor.tag)?.let { teamName -> + moduleContributorsByTeam.getOrPut(teamName) { mutableListOf() }.add(contributor) + } + mapping.getLibraryOwner(contributor.tag)?.let { teamName -> + libContributorsByTeam.getOrPut(teamName) { mutableListOf() }.add(contributor) + } + } + + // Create teams from grouped contributors + val allTeamNames = moduleContributorsByTeam.keys + libContributorsByTeam.keys + allTeamNames.map { teamName -> + Team( + teamName, + moduleContributorsByTeam[teamName] ?: emptyList(), + libContributorsByTeam[teamName] ?: emptyList() + ) } - }.map { Team(it.key, it.value) } + } ?: emptyList() } internal fun List.sort(): List { @@ -91,11 +125,11 @@ internal fun Set.toModules(): List { } } -internal fun Module.toReportItem(moduleToTeamMap: Map): ReportItem = +internal fun Module.toReportItem(teamMapping: TeamMapping?): ReportItem = ReportItem( name = tag, id = tag, - owner = moduleToTeamMap[tag], + owner = teamMapping?.getModuleOwner(tag), extraInfo = "Sum up all codebase for $tag", totalDownloadSize = getDownloadSize(), classesDownloadSize = classDownloadSize, diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/validation/TeamValidationResult.kt b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/validation/TeamValidationResult.kt new file mode 100644 index 0000000..24fc9bb --- /dev/null +++ b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/validation/TeamValidationResult.kt @@ -0,0 +1,101 @@ +/* + * MIT License + * + * Copyright (c) 2026. Grabtaxi Holdings Pte Ltd (GRAB), All rights reserved. + * + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE + */ + +package com.grab.sizer.analyzer.validation + +/** + * Sealed class representing the result of team validation between module and library mappings. + */ +sealed class TeamValidationResult { + /** + * Represents successful validation with no issues found. + */ + data class Success(val message: String = "Team configuration is consistent") : TeamValidationResult() + + /** + * Represents validation that found issues but can still proceed. + */ + data class Warnings(val issues: List) : TeamValidationResult() +} + +/** + * Sealed class representing different types of validation issues. + */ +sealed class ValidationIssue { + abstract val severity: ValidationSeverity + abstract val message: String + abstract val suggestion: String? + + /** + * Teams that exist in module mapping but not in library mapping. + */ + data class TeamsOnlyInModules( + val teams: Set + ) : ValidationIssue() { + override val severity: ValidationSeverity = ValidationSeverity.WARNING + override val message: String = "Teams defined in module ownership but missing in library ownership: ${teams.joinToString(", ")}" + override val suggestion: String = "Consider adding these teams to library ownership or removing unused teams." + } + + /** + * Teams that exist in library mapping but not in module mapping. + */ + data class TeamsOnlyInLibraries( + val teams: Set + ) : ValidationIssue() { + override val severity: ValidationSeverity = ValidationSeverity.WARNING + override val message: String = "Library-only teams detected (teams that own libraries but no modules): ${teams.joinToString(", ")}" + override val suggestion: String = "This could indicate valid library-only teams (e.g., DevOps, Infrastructure teams), missing team definitions in module ownership, or typos in team names between mappings. Review your team structure to ensure this is intentional." + } + + /** + * Teams with similar names that might be typos. + */ + data class SimilarTeamNames( + val potentialTypos: List + ) : ValidationIssue() { + override val severity: ValidationSeverity = ValidationSeverity.WARNING + override val message: String = "Library-only teams with similar names to existing module teams (potential typos)" + override val suggestion: String = "Please verify if these are intentional team name variations or typos that should be corrected." + } +} + +/** + * Represents a potential typo between library team name and module team name. + */ +data class TeamSimilarity( + val libraryTeam: String, + val moduleTeam: String, + val similarity: Double +) + +/** + * Severity levels for validation issues. + */ +enum class ValidationSeverity { + INFO, WARNING, ERROR +} diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/validation/TeamValidator.kt b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/validation/TeamValidator.kt new file mode 100644 index 0000000..4e785b1 --- /dev/null +++ b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/validation/TeamValidator.kt @@ -0,0 +1,201 @@ +/* + * MIT License + * + * Copyright (c) 2026. Grabtaxi Holdings Pte Ltd (GRAB), All rights reserved. + * + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE + */ + +package com.grab.sizer.analyzer.validation + +import com.grab.sizer.analyzer.TeamMapping + +/** + * Pure business logic class for validating team consistency. + * Consumes TeamMapping as a data provider - clean inversion of control. + * Contains no side effects - only returns structured validation results. + */ +class TeamValidator { + + /** + * Validates team consistency using TeamMapping as data provider. + * This is the primary validation entry point that uses inversion of control. + * + * @param teamMapping Data provider for team information + * @return TeamValidationResult with structured validation results + */ + fun validateTeamMapping(teamMapping: TeamMapping): TeamValidationResult { + val libraryTeams = teamMapping.getLibraryTeams() + + // Skip validation if no libraries are configured + if (libraryTeams.isEmpty()) { + return TeamValidationResult.Success("No libraries configured for validation") + } + + return validateTeamConsistency( + moduleTeams = teamMapping.getModuleTeams(), + libraryTeams = libraryTeams + ) + } + + /** + * Validates team consistency between module and library mappings. + * Lower-level method for direct testing of business logic. + * + * @param moduleTeams Set of team names from module mapping + * @param libraryTeams Set of team names from library mapping + * @return TeamValidationResult with structured validation results + */ + fun validateTeamConsistency( + moduleTeams: Set, + libraryTeams: Set + ): TeamValidationResult { + val issues = mutableListOf() + + // Check for teams that exist in one mapping but not the other + val onlyInModules = moduleTeams - libraryTeams + val onlyInLibraries = libraryTeams - moduleTeams + + if (onlyInModules.isNotEmpty()) { + issues.add( + ValidationIssue.TeamsOnlyInModules(teams = onlyInModules) + ) + } + + if (onlyInLibraries.isNotEmpty()) { + issues.add( + ValidationIssue.TeamsOnlyInLibraries(teams = onlyInLibraries) + ) + + // Check for similar team names (potential typos) - only for library-only teams + // This helps identify if a library-only team might be a typo of an existing module team + val similarTeams = findSimilarLibraryOnlyTeams(onlyInLibraries, moduleTeams) + if (similarTeams.isNotEmpty()) { + issues.add(ValidationIssue.SimilarTeamNames(similarTeams)) + } + } + + return if (issues.isEmpty()) { + TeamValidationResult.Success() + } else { + TeamValidationResult.Warnings(issues) + } + } + + /** + * Finds library-only teams that have similar names to existing module teams (potential typos). + * Optimized with early termination and length-based filtering. + */ + private fun findSimilarLibraryOnlyTeams( + libraryOnlyTeams: Set, + moduleTeams: Set + ): List { + val similarPairs = mutableListOf() + val threshold = 0.7 + + for (libraryTeam in libraryOnlyTeams) { + var bestMatch: TeamSimilarity? = null + + for (moduleTeam in moduleTeams) { + // Skip if length difference is too large for 70% similarity + val lengthDiff = kotlin.math.abs(libraryTeam.length - moduleTeam.length) + val maxLength = kotlin.math.max(libraryTeam.length, moduleTeam.length) + if (lengthDiff.toDouble() / maxLength > (1 - threshold)) continue + + val similarity = calculateStringSimilarity(libraryTeam, moduleTeam) + + // Consider teams similar if similarity > 0.7 (70%) + if (similarity > threshold) { + val candidate = TeamSimilarity( + libraryTeam = libraryTeam, + moduleTeam = moduleTeam, + similarity = similarity + ) + // Keep only the best match for each library team + if (bestMatch == null || similarity > bestMatch.similarity) { + bestMatch = candidate + } + } + } + + bestMatch?.let { similarPairs.add(it) } + } + + return similarPairs + } + + /** + * Calculates string similarity using normalized Levenshtein distance. + * + * @return A value between 0.0 (no similarity) and 1.0 (identical strings) + */ + private fun calculateStringSimilarity(str1: String, str2: String): Double { + val longer = if (str1.length > str2.length) str1.lowercase() else str2.lowercase() + val shorter = if (str1.length > str2.length) str2.lowercase() else str1.lowercase() + + if (longer.isEmpty()) return 1.0 + + // Use Levenshtein distance for similarity calculation + val editDistance = levenshteinDistance(longer, shorter) + return (longer.length - editDistance) / longer.length.toDouble() + } + + /** + * Calculates the Levenshtein distance between two strings with memory bounds. + * The Levenshtein distance is the minimum number of single-character edits + * (insertions, deletions, or substitutions) required to change one word into another. + * + * @param str1 First string + * @param str2 Second string + * @return The Levenshtein distance as an integer + */ + private fun levenshteinDistance(str1: String, str2: String): Int { + // Limit memory allocation for very long strings + val maxLen = 100 + val s1 = if (str1.length > maxLen) str1.take(maxLen) else str1 + val s2 = if (str2.length > maxLen) str2.take(maxLen) else str2 + + // Use space-optimized version for large strings (O(min(m,n)) space instead of O(m*n)) + if (s1.length > s2.length) return levenshteinDistance(s2, s1) + + var previousRow = IntArray(s1.length + 1) { it } + var currentRow = IntArray(s1.length + 1) + + for (i in 1..s2.length) { + currentRow[0] = i + for (j in 1..s1.length) { + val cost = if (s2[i - 1] == s1[j - 1]) 0 else 1 + currentRow[j] = minOf( + previousRow[j] + 1, // deletion + currentRow[j - 1] + 1, // insertion + previousRow[j - 1] + cost // substitution + ) + } + // Swap rows + val temp = previousRow + previousRow = currentRow + currentRow = temp + } + + return previousRow[s1.length] + } +} diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/validation/ValidationReporter.kt b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/validation/ValidationReporter.kt new file mode 100644 index 0000000..334603f --- /dev/null +++ b/app-sizer/src/main/kotlin/com/grab/sizer/analyzer/validation/ValidationReporter.kt @@ -0,0 +1,88 @@ +/* + * MIT License + * + * Copyright (c) 2026. Grabtaxi Holdings Pte Ltd (GRAB), All rights reserved. + * + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE + */ + +package com.grab.sizer.analyzer.validation + +import com.grab.sizer.utils.Logger +import com.grab.sizer.utils.log + +/** + * Handles the conversion from structured validation results to user-facing log messages. + * Maintains exact same logging format for backward compatibility. + */ +class ValidationReporter( + private val logger: Logger +) { + + /** + * Reports validation results to the logger, maintaining backward compatibility + * with existing log message format. + */ + fun reportValidationResult(result: TeamValidationResult) { + when (result) { + is TeamValidationResult.Success -> { + // Skip verbose success messages to reduce noise + // Only print if explicitly needed for debugging + } + is TeamValidationResult.Warnings -> { + result.issues.forEach { issue -> + reportValidationIssue(issue) + } + } + } + } + + private fun reportValidationIssue(issue: ValidationIssue) { + when (issue) { + is ValidationIssue.TeamsOnlyInModules -> { + // Skip for now - module ownership is the source of truth. + } + + is ValidationIssue.TeamsOnlyInLibraries -> { + logger.log("⚠️ WARNING: Library-only teams detected (teams that own libraries but no modules):") + issue.teams.forEach { team -> + logger.log(" - '$team' (from libraries)") + } + logger.log(" This could indicate:") + logger.log(" • Missing team definitions in modules") + logger.log(" • Typos in team names between files") + logger.log(" Review your team structure to ensure this is intentional.") + logger.log("") + } + + is ValidationIssue.SimilarTeamNames -> { + logger.log("⚠️ WARNING: Library-only teams with similar names to existing module teams (potential typos):") + issue.potentialTypos.forEach { similarity -> + logger.log(" - Library team: '${similarity.libraryTeam}' (from libraries)") + logger.log(" - Similar to module team: '${similarity.moduleTeam}' (from modules)") + logger.log(" Similarity: ${(similarity.similarity * 100).toInt()}% - Please verify if '${similarity.libraryTeam}' should be '${similarity.moduleTeam}'") + logger.log("") + } + } + } + } +} diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/di/AnalyzerModule.kt b/app-sizer/src/main/kotlin/com/grab/sizer/di/AnalyzerModule.kt index 6a2b025..9aa4195 100644 --- a/app-sizer/src/main/kotlin/com/grab/sizer/di/AnalyzerModule.kt +++ b/app-sizer/src/main/kotlin/com/grab/sizer/di/AnalyzerModule.kt @@ -34,6 +34,7 @@ import com.grab.sizer.analyzer.* import com.grab.sizer.parser.DataParser import com.grab.sizer.parser.DefaultDataParser import com.grab.sizer.utils.InputProvider +import com.grab.sizer.utils.Logger import dagger.Binds import dagger.Module import dagger.Provides @@ -57,12 +58,15 @@ object AnalyzerModule { @Provides fun provideTeamMapping( - inputProvider: InputProvider - ): TeamMapping { - // Todo : Remove this logic from dagger module, possible remove DummyTeamMapping - val ownerMapping = inputProvider.provideTeamMappingFile() - return if (ownerMapping == null) DummyTeamMapping() - else YmlTeamMapping(ownerMapping) + inputProvider: InputProvider, + logger: Logger + ): TeamMapping? { + val moduleFile = inputProvider.provideTeamMappingFile() + val libraryFile = inputProvider.provideLibraryOwnershipFile() + + return if (moduleFile != null) { + TeamMapping.createWithValidation(moduleFile, libraryFile, logger) + } else null } @Provides @@ -78,8 +82,8 @@ internal interface AnalyzerBinder { @Binds @IntoMap - @AnalyticsOptionKey(AnalyticsOption.CODEBASE) - fun bindGeneralAnalyzer(analyzer: CodebaseAnalyzer): Analyzer + @AnalyticsOptionKey(AnalyticsOption.TEAMS) + fun bindTeamAnalyzer(analyzer: TeamAnalyzer): Analyzer @Binds @IntoMap @@ -110,4 +114,4 @@ internal interface AnalyzerBinder { @IntoMap @AnalyticsOptionKey(AnalyticsOption.LARGE_FILE) fun bindLargeFileAnalyzer(analyser: LargeFileAnalyzer): Analyzer -} \ No newline at end of file +} diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/report/MarkdownReportWriter.kt b/app-sizer/src/main/kotlin/com/grab/sizer/report/MarkdownReportWriter.kt index 68e0194..45364c6 100644 --- a/app-sizer/src/main/kotlin/com/grab/sizer/report/MarkdownReportWriter.kt +++ b/app-sizer/src/main/kotlin/com/grab/sizer/report/MarkdownReportWriter.kt @@ -46,7 +46,7 @@ class MarkdownReportWriter @Inject constructor( initOutPutFile() writeText( MarkdownTable(report.createHeader()).apply { - report.rows.sortedBy { it.size() }.forEach { row -> + report.rows.forEach { row -> addRow(row.toMarkDown()) } }.generate() @@ -80,8 +80,6 @@ class MarkdownReportWriter @Inject constructor( createNewFile() } } - - private fun Row.size(): Long = fields.find { it.name == "size" }?.value as Long } internal fun Long.reportSize(): String = when { diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/report/ReportWriter.kt b/app-sizer/src/main/kotlin/com/grab/sizer/report/ReportWriter.kt index 380328b..8c87935 100644 --- a/app-sizer/src/main/kotlin/com/grab/sizer/report/ReportWriter.kt +++ b/app-sizer/src/main/kotlin/com/grab/sizer/report/ReportWriter.kt @@ -59,6 +59,8 @@ data class Row( val fields: List ) +fun Row.size(): Long = fields.find { it.name == "size" }?.value as Long + interface Field { val name: String val value: Any diff --git a/app-sizer/src/main/kotlin/com/grab/sizer/utils/InputProvider.kt b/app-sizer/src/main/kotlin/com/grab/sizer/utils/InputProvider.kt index 8a64c93..65b2306 100644 --- a/app-sizer/src/main/kotlin/com/grab/sizer/utils/InputProvider.kt +++ b/app-sizer/src/main/kotlin/com/grab/sizer/utils/InputProvider.kt @@ -45,6 +45,7 @@ interface InputProvider { fun provideApkFiles(): Sequence fun provideR8MappingFile(): File? fun provideTeamMappingFile(): File? + fun provideLibraryOwnershipFile(): File? fun provideLargeFileThreshold(): Long } diff --git a/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/CodebaseAnalyzerTest.kt b/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/CodebaseAnalyzerTest.kt deleted file mode 100644 index 8d20d18..0000000 --- a/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/CodebaseAnalyzerTest.kt +++ /dev/null @@ -1,157 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2024. Grabtaxi Holdings Pte Ltd (GRAB), All rights reserved. - * - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE - */ - -package com.grab.sizer.analyzer - -import com.grab.sizer.parser.AarFileInfo -import com.grab.sizer.parser.JarFileInfo -import com.grab.sizer.report.* -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull - -class CodebaseAnalyzerTest { - private val mapperComponent = MapperComponent() - private val project1Data = Project1Data() - private val analyzer = CodebaseAnalyzer( - apkComponentProcessor = mapperComponent.apkComponentProcessor, - dataParser = project1Data.fakeDataPasser, - teamMapping = project1Data.teamMapping - ) - - @Test - fun testCodebaseAnalyzerWithProject1Data() { - val report = analyzer.process().sort() - assertEquals(expectedProject1Report.sort(), report) - } - - @Test - fun testCodebaseAnalyzerShouldReportCorrectNumberOfTeams() { - val report = analyzer.process() - assertEquals(2, report.rows.size, "Project1 report should contain exactly 2 teams") - } - - @Test - fun testCodebaseAnalyzerShouldReportCorrectTeamNames() { - val report = analyzer.process() - val teamNames = report.rows.map { it.name }.toSet() - val expectedTeamNames = setOf("team1", "team2") - assertEquals(expectedTeamNames, teamNames, "Should report the correct team names") - } - - @Test - fun testCodebaseAnalyzerShouldReportCorrectTeam1Size() { - val report = analyzer.process() - val team1Row = report.rows.find { it.name == "team1" } - assertNotNull(team1Row, "Project1 report should contain team1") - assertEquals(103L, team1Row.fields.find { it.name == FIELD_KEY_SIZE }?.value) - } - - @Test - fun testCodebaseAnalyzerShouldReportCorrectTeam2Size() { - val report = analyzer.process() - val team2Row = report.rows.find { it.name == "team2" } - assertNotNull(team2Row, "Project1 report should contain team2") - assertEquals(107L, team2Row.fields.find { it.name == FIELD_KEY_SIZE }?.value) - } - - @Test - fun testCodebaseAnalyzerShouldReportCorrectContributorFields() { - val report = analyzer.process() - report.rows.forEach { row -> - val contributorField = row.fields.find { it.name == FIELD_KEY_CONTRIBUTOR } - assertNotNull(contributorField, "Each row should have a contributor field") - assertEquals(row.name, contributorField.value, "Contributor should match the team name") - } - } - - @Test - fun testCodebaseAnalyzerShouldHandleModuleAarNotBelongToBuildFolder() { - val project2Data = Project2Data() - val project2Analyzer = CodebaseAnalyzer( - apkComponentProcessor = mapperComponent.apkComponentProcessor, - dataParser = project2Data.fakeDataPasser, - teamMapping = project2Data.teamMapping - ) - - val report = project2Analyzer.process() - assertEquals(expectedProject2Report, report) - } - - private val expectedProject1Report = Report( - id = "team", - name = "team", - rows = listOf( - Row( - name = "team2", - fields = listOf( - TagField(name = FIELD_KEY_CONTRIBUTOR, value = "team2"), - DefaultField(name = FIELD_KEY_SIZE, value = 107L) - ) - ), - Row( - name = "team1", - fields = listOf( - TagField(name = FIELD_KEY_CONTRIBUTOR, value = "team1"), - DefaultField(name = FIELD_KEY_SIZE, value = 103L) - ) - ) - ) - ) - - private val expectedProject2Report = Report( - id = "team", - name = "team", - rows = listOf( - Row( - name = "team2", - fields = listOf( - TagField(name = FIELD_KEY_CONTRIBUTOR, value = "team2"), - DefaultField(name = FIELD_KEY_SIZE, value = 107L) - ) - ), - Row( - name = "team1", - fields = listOf( - TagField(name = FIELD_KEY_CONTRIBUTOR, value = "team1"), - DefaultField(name = FIELD_KEY_SIZE, value = 103L) - ) - ) - ) - ) -} - -class Project2Data : Project1Data() { - override val moduleAar1: AarFileInfo - get() = super.moduleAar1.copy(path = "aar/moduleAar1.aar") - override val moduleAar2: AarFileInfo - get() = super.moduleAar2.copy(path = "aar/moduleAar2.aar") - override val moduleJar1: JarFileInfo - get() = super.moduleJar1.copy(path = "jar/moduleJar1.jar") - override val moduleJar2: JarFileInfo - get() = super.moduleJar2.copy(path = "jar/moduleJar2.jar") -} \ No newline at end of file diff --git a/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/LibrariesAnalyzerTest.kt b/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/LibrariesAnalyzerTest.kt index 3dc8647..4da77c0 100644 --- a/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/LibrariesAnalyzerTest.kt +++ b/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/LibrariesAnalyzerTest.kt @@ -36,7 +36,8 @@ class LibrariesAnalyzerTest { private val project1Data = Project1Data() private val analyzer = LibrariesAnalyzer( apkComponentProcessor = mapperComponent.apkComponentProcessor, - dataParser = project1Data.fakeDataPasser + dataParser = project1Data.fakeDataPasser, + teamMapping = null // Optional teamMapping for tests ) @Test diff --git a/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/Project1Data.kt b/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/Project1Data.kt index 959c880..16a7e08 100644 --- a/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/Project1Data.kt +++ b/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/Project1Data.kt @@ -177,15 +177,30 @@ open class Project1Data { ) val teamMapping = object : TeamMapping { - override val teamToModuleMap: Map> = mapOf( + private val teamToModuleMap: Map> = mapOf( TEAM_1 to listOf(moduleAar1.name, moduleJar1.name), TEAM_2 to listOf(moduleAar2.name, moduleJar2.name), ) - override val moduleToTeamMap: Map = mapOf( + private val moduleToTeamMap: Map = mapOf( moduleAar1.name to TEAM_1, moduleJar1.name to TEAM_1, moduleAar2.name to TEAM_2, moduleJar2.name to TEAM_2 ) + + // Library ownership mapping + private val libraryToTeamMap: Map = mapOf( + "libAar1" to TEAM_1, // 15 bytes to team1 + "libAar2" to TEAM_2, // 40 bytes to team2 + "libJar" to TEAM_1 // 7 bytes to team1 + ) + + override fun getModuleOwner(moduleName: String): String? = moduleToTeamMap[moduleName] + + override fun getLibraryOwner(libraryCoordinate: String): String? = libraryToTeamMap[libraryCoordinate] + + override fun getAllTeams(): Set = setOf(TEAM_1, TEAM_2) + override fun getModuleTeams(): Set = teamToModuleMap.keys + override fun getLibraryTeams(): Set = libraryToTeamMap.values.toSet() } } \ No newline at end of file diff --git a/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/TeamAnalyzerTest.kt b/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/TeamAnalyzerTest.kt new file mode 100644 index 0000000..e655f5d --- /dev/null +++ b/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/TeamAnalyzerTest.kt @@ -0,0 +1,329 @@ +/* + * MIT License + * + * Copyright (c) 2024. Grabtaxi Holdings Pte Ltd (GRAB), All rights reserved. + * + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE + */ + +package com.grab.sizer.analyzer + +import com.grab.sizer.parser.AarFileInfo +import com.grab.sizer.parser.JarFileInfo +import com.grab.sizer.report.* +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class TeamAnalyzerTest { + private val mapperComponent = MapperComponent() + private val project1Data = Project1Data() + private val analyzer = TeamAnalyzer( + apkComponentProcessor = mapperComponent.apkComponentProcessor, + dataParser = project1Data.fakeDataPasser, + teamMapping = project1Data.teamMapping + ) + + @Test + fun testTeamAnalyzerWithProject1Data() { + val report = analyzer.process().sort() + + // Verify report structure + assertEquals("team", report.id) + assertEquals("team", report.name) + assertEquals(16, report.rows.size, "Should have 8 breakdown rows per team * 2 teams = 16 total") + + // Verify team1 total size (modules: 103 + libraries: libAar1=15 + libJar=7 = 125 total) + val team1TotalRow = report.rows.find { row -> + row.name == "team1" && row.fields.find { it.name == FIELD_KEY_CONTRIBUTOR }?.value == "total" + } + assertNotNull(team1TotalRow, "Should have team1 total row") + assertEquals(125L, team1TotalRow.fields.find { it.name == FIELD_KEY_SIZE }?.value) + + // Verify team2 total size (modules: 107 + libraries: libAar2=40 = 147 total) + val team2TotalRow = report.rows.find { row -> + row.name == "team2" && row.fields.find { it.name == FIELD_KEY_CONTRIBUTOR }?.value == "total" + } + assertNotNull(team2TotalRow, "Should have team2 total row") + assertEquals(147L, team2TotalRow.fields.find { it.name == FIELD_KEY_SIZE }?.value) + + // Verify all teams have complete breakdown rows + val team1Rows = report.rows.filter { row -> + val ownerField = row.fields.find { it.name == FIELD_KEY_OWNER } + ownerField?.value == "team1" + } + assertEquals(8, team1Rows.size, "team1 should have 8 breakdown rows") + + val team2Rows = report.rows.filter { row -> + val ownerField = row.fields.find { it.name == FIELD_KEY_OWNER } + ownerField?.value == "team2" + } + assertEquals(8, team2Rows.size, "team2 should have 8 breakdown rows") + } + + @Test + fun testTeamAnalyzerShouldReportCorrectNumberOfRows() { + val report = analyzer.process() + // Each team should have 8 breakdown rows: total, android-java-libraries, native-libraries, codebase-kotlin-java, codebase-resources, codebase-assets, codebase-native, others + assertEquals(16, report.rows.size, "Project1 report should contain 8 rows per team (2 teams * 8 = 16)") + } + + @Test + fun testTeamAnalyzerShouldReportCorrectRowNames() { + val report = analyzer.process() + val teamNames = report.rows.map { it.name }.toSet() + val expectedTeamNames = setOf("team1", "team2") + assertEquals(expectedTeamNames, teamNames, "Should report the correct team names") + + // Check that we have the correct tags for each team + val team1Tags = report.rows.filter { it.name == "team1" }.mapNotNull { row -> + row.fields.find { it.name == FIELD_KEY_CONTRIBUTOR }?.value + }.toSet() + val expectedTags = setOf("total", "android-java-libraries", "native-libraries", "codebase-kotlin-java", "codebase-resources", "codebase-assets", "codebase-native", "others") + assertEquals(expectedTags, team1Tags, "team1 should have all breakdown tags") + } + + @Test + fun testTeamAnalyzerShouldReportCorrectTeam1Size() { + val report = analyzer.process() + val team1TotalRow = report.rows.find { row -> + row.name == "team1" && row.fields.find { it.name == FIELD_KEY_CONTRIBUTOR }?.value == "total" + } + assertNotNull(team1TotalRow, "Project1 report should contain team1 total size row") + assertEquals(125L, team1TotalRow.fields.find { it.name == FIELD_KEY_SIZE }?.value) + } + + @Test + fun testTeamAnalyzerShouldReportCorrectTeam2Size() { + val report = analyzer.process() + val team2TotalRow = report.rows.find { row -> + row.name == "team2" && row.fields.find { it.name == FIELD_KEY_CONTRIBUTOR }?.value == "total" + } + assertNotNull(team2TotalRow, "Project1 report should contain team2 total size row") + assertEquals(147L, team2TotalRow.fields.find { it.name == FIELD_KEY_SIZE }?.value) + } + + @Test + fun testTeamAnalyzerShouldReportCorrectContributorFields() { + val report = analyzer.process() + // Check that each row has proper contributor field showing metric type and owner field showing team name + val team1TotalRow = report.rows.find { row -> + row.name == "team1" && row.fields.find { it.name == FIELD_KEY_CONTRIBUTOR }?.value == "total" + } + assertNotNull(team1TotalRow, "Should have team1 total row") + val contributorField = team1TotalRow.fields.find { it.name == FIELD_KEY_CONTRIBUTOR } + assertNotNull(contributorField, "Each row should have a contributor field") + assertEquals("total", contributorField.value, "Contributor should show metric type") + + val ownerField = team1TotalRow.fields.find { it.name == FIELD_KEY_OWNER } + assertNotNull(ownerField, "Each row should have an owner field") + assertEquals("team1", ownerField.value, "Owner should match the team name") + } + + @Test + fun testTeamAnalyzerWithLibraryContributions() { + // Create test data with library ownership + val projectDataWithLibs = ProjectDataWithLibraryOwnership() + val analyzerWithLibs = TeamAnalyzer( + apkComponentProcessor = mapperComponent.apkComponentProcessor, + dataParser = projectDataWithLibs.fakeDataPasser, + teamMapping = projectDataWithLibs.teamMapping + ) + + val report = analyzerWithLibs.process() + + // Verify correct number of rows: 2 teams * 8 breakdown rows each = 16 total + assertEquals(16, report.rows.size) + + // Get actual sizes for validation from the total rows + val team1TotalRow = report.rows.find { row -> + row.name == "team1" && row.fields.find { it.name == FIELD_KEY_CONTRIBUTOR }?.value == "total" + } + assertNotNull(team1TotalRow, "Should contain team1 total row") + val team1Size = team1TotalRow.fields.find { it.name == FIELD_KEY_SIZE }?.value as Long + + val team2TotalRow = report.rows.find { row -> + row.name == "team2" && row.fields.find { it.name == FIELD_KEY_CONTRIBUTOR }?.value == "total" + } + assertNotNull(team2TotalRow, "Should contain team2 total row") + val team2Size = team2TotalRow.fields.find { it.name == FIELD_KEY_SIZE }?.value as Long + + // Get sizes from Project1Data (which now includes some library ownership) + val originalReport = analyzer.process() + val originalTeam1Size = originalReport.rows.find { row -> + row.name == "team1" && row.fields.find { it.name == FIELD_KEY_CONTRIBUTOR }?.value == "total" + }?.fields?.find { it.name == FIELD_KEY_SIZE }?.value as Long + val originalTeam2Size = originalReport.rows.find { row -> + row.name == "team2" && row.fields.find { it.name == FIELD_KEY_CONTRIBUTOR }?.value == "total" + }?.fields?.find { it.name == FIELD_KEY_SIZE }?.value as Long + + // ProjectDataWithLibraryOwnership gives team1 libAar1 (15 bytes) and team2 libAar2 (40 bytes) + // but doesn't assign libJar1 (7 bytes) to anyone (unlike Project1Data which assigns it to team1) + // So the differences should be: + // team1: should be 7 bytes less than Project1Data (loses libJar1) + // team2: should be same as Project1Data (gets libAar2 in both) + + assertEquals(118L, team1Size, "team1 should have modules (103) + libAar1 (15) = 118") + assertEquals(147L, team2Size, "team2 should have modules (107) + libAar2 (40) = 147") + + // Verify the changes are as expected + assertEquals(-7L, team1Size - originalTeam1Size, "team1 should lose libJar1 (7 bytes)") + assertEquals(0L, team2Size - originalTeam2Size, "team2 should have same library contribution") + } + + @Test + fun testTeamAnalyzerWithLibraryOnlyTeams() { + // Test scenario where some teams only own libraries (no modules) + val projectDataLibraryOnly = ProjectDataWithLibraryOnlyTeams() + val analyzerLibraryOnly = TeamAnalyzer( + apkComponentProcessor = mapperComponent.apkComponentProcessor, + dataParser = projectDataLibraryOnly.fakeDataPasser, + teamMapping = projectDataLibraryOnly.teamMapping + ) + + val report = analyzerLibraryOnly.process() + + // Should have 3 teams * 8 breakdown rows each = 24 total rows + assertEquals(24, report.rows.size, "Should include all teams including library-only teams") + + val totalRowNames = report.rows.filter { row -> + row.fields.find { it.name == FIELD_KEY_CONTRIBUTOR }?.value == "total" + }.map { it.name }.toSet() + val expectedTotalRows = setOf("team1", "team2", "libraryTeam") + assertEquals(expectedTotalRows, totalRowNames, "Should include total rows for all teams including library-only team") + + // Verify library-only team has correct size + val libraryOnlyTotalRow = report.rows.find { row -> + row.name == "libraryTeam" && row.fields.find { it.name == FIELD_KEY_CONTRIBUTOR }?.value == "total" + } + assertNotNull(libraryOnlyTotalRow, "Library-only team total row should be included") + + // libraryTeam owns libJar1 (7 bytes) + val libraryTeamSize = libraryOnlyTotalRow.fields.find { it.name == FIELD_KEY_SIZE }?.value as Long + assertEquals(7L, libraryTeamSize, "Library-only team should have library size (7 bytes)") + } + + @Test + fun testTeamAnalyzerShouldHandleModuleAarNotBelongToBuildFolder() { + val project2Data = Project2Data() + val project2Analyzer = TeamAnalyzer( + apkComponentProcessor = mapperComponent.apkComponentProcessor, + dataParser = project2Data.fakeDataPasser, + teamMapping = project2Data.teamMapping + ) + + val report = project2Analyzer.process() + + // Verify basic structure (same as Project1 but with different paths) + assertEquals(16, report.rows.size, "Should have 8 breakdown rows per team * 2 teams = 16 total") + + // Verify team total sizes are the same as Project1 (since data should be equivalent including libraries) + val team1TotalRow = report.rows.find { row -> + row.name == "team1" && row.fields.find { it.name == FIELD_KEY_CONTRIBUTOR }?.value == "total" + } + assertNotNull(team1TotalRow, "Should have team1 total row") + assertEquals(125L, team1TotalRow.fields.find { it.name == FIELD_KEY_SIZE }?.value) + + val team2TotalRow = report.rows.find { row -> + row.name == "team2" && row.fields.find { it.name == FIELD_KEY_CONTRIBUTOR }?.value == "total" + } + assertNotNull(team2TotalRow, "Should have team2 total row") + assertEquals(147L, team2TotalRow.fields.find { it.name == FIELD_KEY_SIZE }?.value) + } +} + +/** + * Test data with library ownership mapping for comprehensive library contribution testing + */ +class ProjectDataWithLibraryOwnership { + private val project1Data = Project1Data() + + val fakeDataPasser = project1Data.fakeDataPasser + + val teamMapping = object : TeamMapping { + override fun getModuleOwner(moduleName: String): String? { + return when (moduleName) { + "moduleAar1", "moduleJar1" -> "team1" + "moduleAar2", "moduleJar2" -> "team2" + else -> null + } + } + + override fun getLibraryOwner(libraryCoordinate: String): String? { + return when (libraryCoordinate) { + "libAar1" -> "team1" // Assign libAar1 (15 bytes) to team1 + "libAar2" -> "team2" // Assign libAar2 (40 bytes) to team2 + "libJar1" -> null // libJar1 (7 bytes) remains unassigned + else -> null + } + } + + override fun getAllTeams(): Set = setOf("team1", "team2") + override fun getModuleTeams(): Set = setOf("team1", "team2") + override fun getLibraryTeams(): Set = setOf("team1", "team2") + } +} + +/** + * Test data with library-only teams to verify teams that own no modules but only libraries + */ +class ProjectDataWithLibraryOnlyTeams { + private val project1Data = Project1Data() + + val fakeDataPasser = project1Data.fakeDataPasser + + val teamMapping = object : TeamMapping { + override fun getModuleOwner(moduleName: String): String? { + return when (moduleName) { + "moduleAar1", "moduleJar1" -> "team1" + "moduleAar2", "moduleJar2" -> "team2" + // No modules assigned to "libraryTeam" + else -> null + } + } + + override fun getLibraryOwner(libraryCoordinate: String): String? { + return when (libraryCoordinate) { + "libAar1" -> "team1" // team1: modules + library + "libAar2" -> null // libAar2 unassigned + "libJar" -> "libraryTeam" // libraryTeam: library ONLY (no modules) - FIXED! + else -> null + } + } + + override fun getAllTeams(): Set = setOf("team1", "team2", "libraryTeam") + override fun getModuleTeams(): Set = setOf("team1", "team2") + override fun getLibraryTeams(): Set = setOf("team1", "libraryTeam") + } +} + +class Project2Data : Project1Data() { + override val moduleAar1: AarFileInfo + get() = super.moduleAar1.copy(path = "aar/moduleAar1.aar") + override val moduleAar2: AarFileInfo + get() = super.moduleAar2.copy(path = "aar/moduleAar2.aar") + override val moduleJar1: JarFileInfo + get() = super.moduleJar1.copy(path = "jar/moduleJar1.jar") + override val moduleJar2: JarFileInfo + get() = super.moduleJar2.copy(path = "jar/moduleJar2.jar") +} \ No newline at end of file diff --git a/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/YmlTeamMappingTest.kt b/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/YmlTeamMappingTest.kt index d283506..09d742c 100644 --- a/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/YmlTeamMappingTest.kt +++ b/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/YmlTeamMappingTest.kt @@ -6,6 +6,10 @@ import org.junit.Rule import org.junit.rules.TemporaryFolder import java.io.File +/** + * Tests for YmlTeamMapping as a pure data provider. + * Validation logic is now tested separately in TeamValidatorTest. + */ class YmlTeamMappingTest { @get:Rule @@ -26,11 +30,18 @@ class YmlTeamMappingTest { writeText(ymlContent) } - val teamMapping = YmlTeamMapping(ymlFile) + val teamMapping = YmlTeamMapping(ymlFile, null) - assertEquals(2, teamMapping.teamToModuleMap.size) - assertEquals(listOf("module1", "group1:module2"), teamMapping.teamToModuleMap["Team1"]) - assertEquals(listOf("module3", "group2:module4"), teamMapping.teamToModuleMap["Team2"]) + // Test core mapping functionality + assertEquals("Team1", teamMapping.getModuleOwner("module1")) + assertEquals("Team1", teamMapping.getModuleOwner("group1:module2")) + assertEquals("Team2", teamMapping.getModuleOwner("module3")) + assertEquals("Team2", teamMapping.getModuleOwner("group2:module4")) + assertEquals(setOf("Team1", "Team2"), teamMapping.getAllTeams()) + + // Test data provider methods + assertEquals(setOf("Team1", "Team2"), teamMapping.getModuleTeams()) + assertEquals(emptySet(), teamMapping.getLibraryTeams()) } @Test @@ -48,13 +59,12 @@ class YmlTeamMappingTest { writeText(ymlContent) } - val teamMapping = YmlTeamMapping(ymlFile) + val teamMapping = YmlTeamMapping(ymlFile, null) - assertEquals(4, teamMapping.moduleToTeamMap.size) - assertEquals("Team1", teamMapping.moduleToTeamMap["module1"]) - assertEquals("Team1", teamMapping.moduleToTeamMap["group1:module2"]) - assertEquals("Team2", teamMapping.moduleToTeamMap["module3"]) - assertEquals("Team2", teamMapping.moduleToTeamMap["group2:module4"]) + assertEquals("Team1", teamMapping.getModuleOwner("module1")) + assertEquals("Team1", teamMapping.getModuleOwner("group1:module2")) + assertEquals("Team2", teamMapping.getModuleOwner("module3")) + assertEquals("Team2", teamMapping.getModuleOwner("group2:module4")) } @Test @@ -68,12 +78,11 @@ class YmlTeamMappingTest { writeText(ymlContent) } - val teamMapping = YmlTeamMapping(ymlFile) + val teamMapping = YmlTeamMapping(ymlFile, null) - assertEquals(2, teamMapping.teamToModuleMap.size) - assertTrue(teamMapping.teamToModuleMap["Team1"]?.isEmpty() ?: false) - assertTrue(teamMapping.teamToModuleMap["Team2"]?.isEmpty() ?: false) - assertTrue(teamMapping.moduleToTeamMap.isEmpty()) + assertEquals(setOf("Team1", "Team2"), teamMapping.getAllTeams()) + assertEquals(setOf("Team1", "Team2"), teamMapping.getModuleTeams()) + assertNull(teamMapping.getModuleOwner("nonexistent")) } @Test @@ -91,15 +100,246 @@ class YmlTeamMappingTest { writeText(ymlContent) } - val teamMapping = YmlTeamMapping(ymlFile) - assertEquals(listOf("module1", "group1:module2"), teamMapping.teamToModuleMap["Team1"]) - assertEquals("Team1", teamMapping.moduleToTeamMap["module1"]) - assertEquals("Team1", teamMapping.moduleToTeamMap["group1:module2"]) + val teamMapping = YmlTeamMapping(ymlFile, null) + assertEquals("Team1", teamMapping.getModuleOwner("module1")) + assertEquals("Team1", teamMapping.getModuleOwner("group1:module2")) } @Test(expected = Exception::class) fun testNonExistentFileThrowsException() { val nonExistentFile = File("non_existent_file.yml") - YmlTeamMapping(nonExistentFile).teamToModuleMap + YmlTeamMapping(nonExistentFile, null).getAllTeams() + } + + // Library ownership pattern matching tests + + @Test + fun testLibraryOwnershipExactMatch() { + val moduleYml = tempFolder.newFile("modules.yml").apply { + writeText("Team1:\n - :module1") + } + val libraryYml = tempFolder.newFile("libraries.yml").apply { + writeText(""" + Platform: + - androidx.core:core:1.8.0 + - com.google.android:material:1.6.1 + Networking: + - com.squareup.okhttp3:okhttp:4.9.3 + """.trimIndent()) + } + + val teamMapping = YmlTeamMapping(moduleYml, libraryYml) + + // Test exact matches + assertEquals("Platform", teamMapping.getLibraryOwner("androidx.core:core:1.8.0")) + assertEquals("Platform", teamMapping.getLibraryOwner("com.google.android:material:1.6.1")) + assertEquals("Networking", teamMapping.getLibraryOwner("com.squareup.okhttp3:okhttp:4.9.3")) + + // Test data provider methods + assertEquals(setOf("Team1"), teamMapping.getModuleTeams()) + assertEquals(setOf("Platform", "Networking"), teamMapping.getLibraryTeams()) + assertEquals(setOf("Team1", "Platform", "Networking"), teamMapping.getAllTeams()) + } + + @Test + fun testLibraryOwnershipArtifactWildcard() { + val moduleYml = tempFolder.newFile("modules.yml").apply { + writeText("Team1:\n - :module1") + } + val libraryYml = tempFolder.newFile("libraries.yml").apply { + writeText(""" + Platform: + - androidx.core:* + - com.google.android:material:* + Networking: + - com.squareup.okhttp3:* + """.trimIndent()) + } + + val teamMapping = YmlTeamMapping(moduleYml, libraryYml) + + // Test artifact wildcard matches + assertEquals("Platform", teamMapping.getLibraryOwner("androidx.core:core:1.8.0")) + assertEquals("Platform", teamMapping.getLibraryOwner("androidx.core:core-ktx:1.8.0")) + assertEquals("Platform", teamMapping.getLibraryOwner("com.google.android:material:1.6.1")) + assertEquals("Networking", teamMapping.getLibraryOwner("com.squareup.okhttp3:okhttp:4.9.3")) + assertEquals("Networking", teamMapping.getLibraryOwner("com.squareup.okhttp3:logging-interceptor:4.9.3")) + } + + @Test + fun testLibraryOwnershipGroupWildcard() { + val moduleYml = tempFolder.newFile("modules.yml").apply { + writeText("Team1:\n - :module1") + } + val libraryYml = tempFolder.newFile("libraries.yml").apply { + writeText(""" + Platform: + - androidx.* + - com.google.android.* + Networking: + - com.squareup.* + """.trimIndent()) + } + + val teamMapping = YmlTeamMapping(moduleYml, libraryYml) + + // Test group wildcard matches + assertEquals("Platform", teamMapping.getLibraryOwner("androidx.core:core:1.8.0")) + assertEquals("Platform", teamMapping.getLibraryOwner("androidx.fragment:fragment:1.5.0")) + assertEquals("Platform", teamMapping.getLibraryOwner("com.google.android.material:material:1.6.1")) + assertEquals("Networking", teamMapping.getLibraryOwner("com.squareup.okhttp3:okhttp:4.9.3")) + assertEquals("Networking", teamMapping.getLibraryOwner("com.squareup.retrofit2:retrofit:2.9.0")) + } + + @Test + fun testLibraryOwnershipPatternPriority() { + val moduleYml = tempFolder.newFile("modules.yml").apply { + writeText("Team1:\n - :module1") + } + val libraryYml = tempFolder.newFile("libraries.yml").apply { + writeText(""" + ExactTeam: + - com.example:library:1.0.0 + ArtifactTeam: + - com.example:library:* + GroupTeam: + - com.example.* + """.trimIndent()) + } + + val teamMapping = YmlTeamMapping(moduleYml, libraryYml) + + // Exact match should win over wildcards + assertEquals("ExactTeam", teamMapping.getLibraryOwner("com.example:library:1.0.0")) + + // Artifact wildcard should win over group wildcard for different versions + assertEquals("ArtifactTeam", teamMapping.getLibraryOwner("com.example:library:2.0.0")) + + // Group wildcard should match different artifacts + assertEquals("GroupTeam", teamMapping.getLibraryOwner("com.example:other:1.0.0")) + } + + @Test + fun testLibraryOwnershipNoMatch() { + val moduleYml = tempFolder.newFile("modules.yml").apply { + writeText("Team1:\n - :module1") + } + val libraryYml = tempFolder.newFile("libraries.yml").apply { + writeText(""" + Platform: + - androidx.* + Networking: + - com.squareup.* + """.trimIndent()) + } + + val teamMapping = YmlTeamMapping(moduleYml, libraryYml) + + // Test coordinates that don't match any patterns + assertNull(teamMapping.getLibraryOwner("com.unknown:library:1.0.0")) + assertNull(teamMapping.getLibraryOwner("org.jetbrains:annotations:23.0.0")) + assertNull(teamMapping.getLibraryOwner("")) + } + + @Test + fun testLibraryOwnershipWithoutLibraryFile() { + val moduleYml = tempFolder.newFile("modules.yml").apply { + writeText("Team1:\n - :module1") + } + + val teamMapping = YmlTeamMapping(moduleYml, null) + + // Should return null for any library coordinate when no library file provided + assertNull(teamMapping.getLibraryOwner("androidx.core:core:1.8.0")) + assertNull(teamMapping.getLibraryOwner("com.squareup.okhttp3:okhttp:4.9.3")) + + // Data provider methods + assertEquals(setOf("Team1"), teamMapping.getModuleTeams()) + assertEquals(emptySet(), teamMapping.getLibraryTeams()) + } + + @Test + fun testLibraryOwnershipComplexPatterns() { + val moduleYml = tempFolder.newFile("modules.yml").apply { + writeText("Team1:\n - :module1") + } + val libraryYml = tempFolder.newFile("libraries.yml").apply { + writeText(""" + Platform: + - androidx.* + - com.google.* + - org.jetbrains.kotlin:* + UI: + - com.github.bumptech.glide:* + - com.facebook.fresco:* + Networking: + - com.squareup.* + - io.reactivex.* + - com.jakewharton.retrofit:* + """.trimIndent()) + } + + val teamMapping = YmlTeamMapping(moduleYml, libraryYml) + + // Test various complex coordinates + assertEquals("Platform", teamMapping.getLibraryOwner("androidx.appcompat:appcompat:1.5.0")) + assertEquals("Platform", teamMapping.getLibraryOwner("com.google.firebase:firebase-core:21.0.0")) + assertEquals("Platform", teamMapping.getLibraryOwner("org.jetbrains.kotlin:kotlin-stdlib:1.7.10")) + + assertEquals("UI", teamMapping.getLibraryOwner("com.github.bumptech.glide:glide:4.13.2")) + assertEquals("UI", teamMapping.getLibraryOwner("com.facebook.fresco:fresco:2.6.0")) + + assertEquals("Networking", teamMapping.getLibraryOwner("com.squareup.okhttp3:okhttp:4.9.3")) + assertEquals("Networking", teamMapping.getLibraryOwner("io.reactivex.rxjava2:rxjava:2.2.21")) + assertEquals("Networking", teamMapping.getLibraryOwner("com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2")) + } + + @Test + fun testLibraryOwnershipSimple() { + val moduleYml = tempFolder.newFile("modules.yml").apply { + writeText("Team1:\n - :module1") + } + val libraryYml = tempFolder.newFile("libraries.yml").apply { + writeText(""" + TestTeam: + - com.example.* + """.trimIndent()) + } + + val teamMapping = YmlTeamMapping(moduleYml, libraryYml) + + // Test basic group wildcard + val result = teamMapping.getLibraryOwner("com.example.library:artifact:1.0.0") + assertEquals("TestTeam", result) + + // Test non-matching + assertNull(teamMapping.getLibraryOwner("com.other:library:1.0.0")) + } + + @Test + fun testDataProviderMethods() { + val moduleYml = tempFolder.newFile("modules.yml").apply { + writeText(""" + Platform: + - app + Team1: + - module1 + """.trimIndent()) + } + val libraryYml = tempFolder.newFile("libraries.yml").apply { + writeText(""" + Platform: + - androidx.* + LibraryOnlyTeam: + - com.example.* + """.trimIndent()) + } + + val teamMapping = YmlTeamMapping(moduleYml, libraryYml) + + // Test data provider methods + assertEquals(setOf("Platform", "Team1"), teamMapping.getModuleTeams()) + assertEquals(setOf("Platform", "LibraryOnlyTeam"), teamMapping.getLibraryTeams()) + assertEquals(setOf("Platform", "Team1", "LibraryOnlyTeam"), teamMapping.getAllTeams()) } -} \ No newline at end of file +} diff --git a/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/validation/TeamValidatorTest.kt b/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/validation/TeamValidatorTest.kt new file mode 100644 index 0000000..772e255 --- /dev/null +++ b/app-sizer/src/test/kotlin/com/grab/sizer/analyzer/validation/TeamValidatorTest.kt @@ -0,0 +1,267 @@ +package com.grab.sizer.analyzer.validation + +import com.grab.sizer.analyzer.TeamMapping +import org.junit.Test +import org.junit.Assert.* + +class TeamValidatorTest { + + private val validator = TeamValidator() + + @Test + fun testTeamValidatorShouldValidateConsistentTeams() { + val teamMapping = createMockTeamMapping( + moduleTeams = setOf("Platform", "Team1"), + libraryTeams = setOf("Platform", "Team1"), + ) + + val result = validator.validateTeamMapping(teamMapping) + + assertTrue("Should be successful", result is TeamValidationResult.Success) + } + + @Test + fun testTeamValidatorShouldValidateInconsistentTeams() { + val teamMapping = createMockTeamMapping( + moduleTeams = setOf("Platform", "OnlyInModules"), + libraryTeams = setOf("Platform", "OnlyInLibraries"), + ) + + val result = validator.validateTeamMapping(teamMapping) + + assertTrue("Should have warnings", result is TeamValidationResult.Warnings) + val warnings = result as TeamValidationResult.Warnings + assertEquals("Should have 2 issues", 2, warnings.issues.size) + + val moduleOnlyIssue = warnings.issues.find { it is ValidationIssue.TeamsOnlyInModules } + assertNotNull("Should detect teams only in modules", moduleOnlyIssue) + + val libraryOnlyIssue = warnings.issues.find { it is ValidationIssue.TeamsOnlyInLibraries } + assertNotNull("Should detect teams only in libraries", libraryOnlyIssue) + } + + @Test + fun testTeamValidatorShouldValidateWithNoLibraryFile() { + val teamMapping = createMockTeamMapping( + moduleTeams = setOf("Platform"), + libraryTeams = emptySet(), + ) + + val result = validator.validateTeamMapping(teamMapping) + + assertTrue("Should be successful", result is TeamValidationResult.Success) + val success = result as TeamValidationResult.Success + assertEquals("No libraries configured for validation", success.message) + } + + @Test + fun testTeamValidatorShouldReturnSuccessWhenTeamsAreConsistent() { + val moduleTeams = setOf("Platform", "Team1", "Team2") + val libraryTeams = setOf("Platform", "Team1", "Team2") + + val result = validator.validateTeamConsistency(moduleTeams, libraryTeams) + + assertTrue("Should be successful", result is TeamValidationResult.Success) + val success = result as TeamValidationResult.Success + assertEquals("Team configuration is consistent", success.message) + } + + @Test + fun testTeamValidatorShouldDetectTeamsOnlyInModules() { + val moduleTeams = setOf("Platform", "Team1", "OnlyInModules") + val libraryTeams = setOf("Platform", "Team1") + + val result = validator.validateTeamConsistency(moduleTeams, libraryTeams) + + assertTrue("Should have warnings", result is TeamValidationResult.Warnings) + val warnings = result as TeamValidationResult.Warnings + + val moduleOnlyIssue = warnings.issues.find { it is ValidationIssue.TeamsOnlyInModules } + assertNotNull("Should have teams only in modules issue", moduleOnlyIssue) + + val teamsOnlyInModules = moduleOnlyIssue as ValidationIssue.TeamsOnlyInModules + assertEquals(setOf("OnlyInModules"), teamsOnlyInModules.teams) + assertEquals(ValidationSeverity.WARNING, teamsOnlyInModules.severity) + } + + @Test + fun testTeamValidatorShouldDetectTeamsOnlyInLibraries() { + val moduleTeams = setOf("Platform", "Team1") + val libraryTeams = setOf("Platform", "Team1", "OnlyInLibraries") + + val result = validator.validateTeamConsistency(moduleTeams, libraryTeams) + + assertTrue("Should have warnings", result is TeamValidationResult.Warnings) + val warnings = result as TeamValidationResult.Warnings + + val libraryOnlyIssue = warnings.issues.find { it is ValidationIssue.TeamsOnlyInLibraries } + assertNotNull("Should have teams only in libraries issue", libraryOnlyIssue) + + val teamsOnlyInLibraries = libraryOnlyIssue as ValidationIssue.TeamsOnlyInLibraries + assertEquals(setOf("OnlyInLibraries"), teamsOnlyInLibraries.teams) + assertEquals(ValidationSeverity.WARNING, teamsOnlyInLibraries.severity) + } + + @Test + fun testTeamValidatorShouldDetectSimilarTeamNamesAsTypos() { + val moduleTeams = setOf("Platform", "Team1") + val libraryTeams = setOf("Platfrom", "Team1s") // typos + + val result = validator.validateTeamConsistency(moduleTeams, libraryTeams) + + assertTrue("Should have warnings", result is TeamValidationResult.Warnings) + val warnings = result as TeamValidationResult.Warnings + + val similarNameIssue = warnings.issues.find { it is ValidationIssue.SimilarTeamNames } + assertNotNull("Should have similar team names issue", similarNameIssue) + + val similarTeamNames = similarNameIssue as ValidationIssue.SimilarTeamNames + val typos = similarTeamNames.potentialTypos + + assertEquals(2, typos.size) + + val platformTypo = typos.find { it.libraryTeam == "Platfrom" } + assertNotNull("Should find Platform typo", platformTypo) + assertEquals("Platform", platformTypo!!.moduleTeam) + assertTrue("Should have high similarity", platformTypo.similarity > 0.7) + + val team1Typo = typos.find { it.libraryTeam == "Team1s" } + assertNotNull("Should find Team1 typo", team1Typo) + assertEquals("Team1", team1Typo!!.moduleTeam) + assertTrue("Should have high similarity", team1Typo.similarity > 0.7) + } + + @Test + fun testTeamValidatorShouldDetectMultipleIssuesAtOnce() { + val moduleTeams = setOf("Platform", "Team1", "OnlyInModules") + val libraryTeams = setOf("Platfrom", "OnlyInLibraries") // Platform is a typo + + val result = validator.validateTeamConsistency(moduleTeams, libraryTeams) + + assertTrue("Should have warnings", result is TeamValidationResult.Warnings) + val warnings = result as TeamValidationResult.Warnings + + assertEquals("Should have 3 issues", 3, warnings.issues.size) + + // Should have teams only in modules + val moduleOnlyIssue = warnings.issues.find { it is ValidationIssue.TeamsOnlyInModules } + assertNotNull("Should detect teams only in modules", moduleOnlyIssue) + + // Should have teams only in libraries + val libraryOnlyIssue = warnings.issues.find { it is ValidationIssue.TeamsOnlyInLibraries } + assertNotNull("Should detect teams only in libraries", libraryOnlyIssue) + + // Should detect similar names (typos) + val similarNamesIssue = warnings.issues.find { it is ValidationIssue.SimilarTeamNames } + assertNotNull("Should detect similar names", similarNamesIssue) + } + + @Test + fun testTeamValidatorShouldNotDetectLowSimilarityAsTypos() { + val moduleTeams = setOf("Platform", "Team1") + val libraryTeams = setOf("CompletelyDifferent", "AnotherTeam") + + val result = validator.validateTeamConsistency(moduleTeams, libraryTeams) + + assertTrue("Should have warnings", result is TeamValidationResult.Warnings) + val warnings = result as TeamValidationResult.Warnings + + // Should have 2 issues: teams only in modules AND teams only in libraries, but no similar names + assertEquals("Should have 2 issues", 2, warnings.issues.size) + + val moduleOnlyIssue = warnings.issues.find { it is ValidationIssue.TeamsOnlyInModules } + assertNotNull("Should detect teams only in modules", moduleOnlyIssue) + + val libraryOnlyIssue = warnings.issues.find { it is ValidationIssue.TeamsOnlyInLibraries } + assertNotNull("Should detect teams only in libraries", libraryOnlyIssue) + + val similarNamesIssue = warnings.issues.find { it is ValidationIssue.SimilarTeamNames } + assertNull("Should not detect similar names for low similarity", similarNamesIssue) + } + + @Test + fun testTeamValidatorShouldHandleEmptyTeamSets() { + val result = validator.validateTeamConsistency(emptySet(), emptySet()) + + assertTrue("Should be successful for empty sets", result is TeamValidationResult.Success) + } + + @Test + fun testTeamValidatorShouldHandleOneEmptyTeamSet() { + val moduleTeams = setOf("Platform") + val libraryTeams = emptySet() + + val result = validator.validateTeamConsistency(moduleTeams, libraryTeams) + + assertTrue("Should have warnings", result is TeamValidationResult.Warnings) + val warnings = result as TeamValidationResult.Warnings + + assertEquals("Should have 1 issue", 1, warnings.issues.size) + val moduleOnlyIssue = warnings.issues.first() as ValidationIssue.TeamsOnlyInModules + assertEquals(setOf("Platform"), moduleOnlyIssue.teams) + } + + @Test + fun testTeamValidatorShouldHandleCaseInsensitiveSimilarityCorrectly() { + val moduleTeams = setOf("PLATFORM") + val libraryTeams = setOf("platform") // different case + + val result = validator.validateTeamConsistency(moduleTeams, libraryTeams) + + assertTrue("Should have warnings", result is TeamValidationResult.Warnings) + val warnings = result as TeamValidationResult.Warnings + + // Should detect both as library-only and similar names due to case difference + val similarNamesIssue = warnings.issues.find { it is ValidationIssue.SimilarTeamNames } + assertNotNull("Should detect similar names (case difference)", similarNamesIssue) + + val similarTeamNames = similarNamesIssue as ValidationIssue.SimilarTeamNames + val similarity = similarTeamNames.potentialTypos.first() + assertEquals("platform", similarity.libraryTeam) + assertEquals("PLATFORM", similarity.moduleTeam) + assertEquals(1.0, similarity.similarity, 0.001) // Should be identical when lowercased + } + + @Test + fun testTeamValidatorShouldCalculateStringSimilarityCorrectly() { + // Test specific similarity cases with expected Levenshtein distance results + val moduleTeams = setOf("Platform", "Team1", "LongTeamName") + val libraryTeams = setOf("Platfrom", "Team1s", "LongTeamNam") + + val result = validator.validateTeamConsistency(moduleTeams, libraryTeams) + + val warnings = result as TeamValidationResult.Warnings + val similarNamesIssue = warnings.issues.find { it is ValidationIssue.SimilarTeamNames } as ValidationIssue.SimilarTeamNames + val typos = similarNamesIssue.potentialTypos + + // Platform -> Platfrom: 2 character substitutions, similarity = (8-2)/8 = 0.75 + val platformTypo = typos.find { it.libraryTeam == "Platfrom" } + assertNotNull("Should find Platform typo", platformTypo) + assertEquals("Platform typo similarity should be 0.75", 0.75, platformTypo!!.similarity, 0.001) + + // Team1 -> Team1s: 1 character addition, similarity = (6-1)/6 ≈ 0.833 + val team1Typo = typos.find { it.libraryTeam == "Team1s" } + assertNotNull("Should find Team1 typo", team1Typo) + assertEquals("Team1 typo similarity should be ~0.833", 5.0/6.0, team1Typo!!.similarity, 0.001) + + // LongTeamName -> LongTeamNam: 1 character deletion, similarity = (12-1)/12 ≈ 0.917 + val longNameTypo = typos.find { it.libraryTeam == "LongTeamNam" } + assertNotNull("Should find LongTeamName typo", longNameTypo) + assertEquals("LongTeamName typo similarity should be ~0.917", 11.0/12.0, longNameTypo!!.similarity, 0.001) + } + + // Helper method to create mock TeamMapping + private fun createMockTeamMapping( + moduleTeams: Set, + libraryTeams: Set, + ): TeamMapping { + return object : TeamMapping { + override fun getModuleOwner(moduleName: String): String? = null + override fun getLibraryOwner(libraryCoordinate: String): String? = null + override fun getAllTeams(): Set = moduleTeams + libraryTeams + + override fun getModuleTeams(): Set = moduleTeams + override fun getLibraryTeams(): Set = libraryTeams + } + } +} diff --git a/cli/src/main/kotlin/com/grab/sizer/AnalyzerCommand.kt b/cli/src/main/kotlin/com/grab/sizer/AnalyzerCommand.kt index 56118fa..32678be 100644 --- a/cli/src/main/kotlin/com/grab/sizer/AnalyzerCommand.kt +++ b/cli/src/main/kotlin/com/grab/sizer/AnalyzerCommand.kt @@ -60,7 +60,7 @@ class AnalyzerCommand : CliktCommand() { "--modules" to AnalyticsOption.MODULES, "--apk" to AnalyticsOption.APK, "--basic" to AnalyticsOption.BASIC, - "--codebase" to AnalyticsOption.CODEBASE, + "--codebase" to AnalyticsOption.TEAMS, "--large-files" to AnalyticsOption.LARGE_FILE, "--lib-content" to AnalyticsOption.LIB_CONTENT, ).default(AnalyticsOption.DEFAULT) @@ -78,7 +78,8 @@ class AnalyzerCommand : CliktCommand() { inputProvider = CliInputProvider( fileQuery = DefaultFileQuery(), config = config, - apksDirectory = apkDirectory + apksDirectory = apkDirectory, + logger = logger ), outputProvider = CliOutputProvider(config, apkDirectory.nameWithoutExtension), libName = libName, diff --git a/cli/src/main/kotlin/com/grab/sizer/CliInputProvider.kt b/cli/src/main/kotlin/com/grab/sizer/CliInputProvider.kt index fe4807b..ef66bb6 100644 --- a/cli/src/main/kotlin/com/grab/sizer/CliInputProvider.kt +++ b/cli/src/main/kotlin/com/grab/sizer/CliInputProvider.kt @@ -30,7 +30,9 @@ package com.grab.sizer import com.grab.sizer.config.Config import com.grab.sizer.utils.FileQuery import com.grab.sizer.utils.InputProvider +import com.grab.sizer.utils.Logger import com.grab.sizer.utils.SizerInputFile +import com.grab.sizer.utils.log import java.io.File @@ -42,6 +44,9 @@ internal const val GRADLE_FILE = "build.gradle" internal const val DEFAULT_AAR_FOLDER = "/build/outputs/aar" private const val BUILD_FOLDER = "build" +// Gradle cache path constants for Maven coordinate extraction +private const val VERSION_REGEX_PATTERN = ".*\\d+.*" + interface FileSystem { fun create(parent: File, path: String): File } @@ -54,6 +59,7 @@ class CliInputProvider constructor( private val fileQuery: FileQuery, private val config: Config, private val apksDirectory: File, + private val logger: Logger, private val fileSystem: FileSystem = DefaultFileSystem() ) : InputProvider { override fun provideModuleAar(): Sequence { @@ -113,7 +119,7 @@ class CliInputProvider constructor( config.projectInput.librariesDirectory, EXT_JAR ).map { file -> SizerInputFile( - tag = file.nameWithoutExtension, + tag = extractMavenCoordinate(file), file = file ) } @@ -122,7 +128,7 @@ class CliInputProvider constructor( config.projectInput.librariesDirectory, EXT_AAR ).map { file -> SizerInputFile( - tag = file.nameWithoutExtension, + tag = extractMavenCoordinate(file), file = file ) } @@ -133,7 +139,63 @@ class CliInputProvider constructor( override fun provideTeamMappingFile(): File? = config.projectInput.ownerMappingFile + override fun provideLibraryOwnershipFile(): File? = config.projectInput.libraryOwnerMappingFile + override fun provideLargeFileThreshold(): Long = config.projectInput.largeFileThreshold + + /** + * Extracts Maven coordinate from Gradle cache path structure with robust fallbacks. + * Path pattern: [gradle-folder]/caches/modules-2/files-2.1/{groupId}/{artifactId}/{version}/{hash}/{filename} + * Example: ~/.gradle/caches/modules-2/files-2.1/androidx.core/core/1.13.1/.../core-1.13.1.aar + * -> androidx.core:core:1.13.1 + * + * Fallbacks: + * 1. If not in Gradle cache: try to parse filename patterns + * 2. If all fails: use filename without extension + */ + private fun extractMavenCoordinate(file: File): String { + val path = file.absolutePath + + // Primary: Extract from official Gradle cache structure + val gradleCachePattern = "caches${File.separator}modules-2${File.separator}files-2.1${File.separator}" + val cacheIndex = path.indexOf(gradleCachePattern) + + if (cacheIndex != -1) { + val afterCache = path.substring(cacheIndex + gradleCachePattern.length) + val pathParts = afterCache.split(File.separator) + + if (pathParts.size >= 3) { + val groupId = pathParts[0] + val artifactId = pathParts[1] + val version = pathParts[2] + return "$groupId:$artifactId:$version" + } + } + + // Fallback 1: Try to parse common filename patterns + val filename = file.nameWithoutExtension + + // Pattern: groupId-artifactId-version (e.g., "androidx.core-core-1.13.1") + val dashParts = filename.split("-") + if (dashParts.size >= 3) { + // Try to identify version pattern (ends with numbers/dots) + for (i in 2 until dashParts.size) { + val potentialVersion = dashParts.subList(i, dashParts.size).joinToString("-") + if (potentialVersion.matches(Regex(VERSION_REGEX_PATTERN))) { // Contains digits + val groupId = dashParts[0] + val artifactId = dashParts.subList(1, i).joinToString("-") + return "$groupId:$artifactId:$potentialVersion" + } + } + } + + // Fallback 2: Use filename as-is (for custom libraries or non-standard structures) + logger.log("⚠️ WARNING: Could not extract Maven coordinate from path: ${file.absolutePath}") + logger.log(" Using filename as coordinate: $filename") + logger.log(" 📋 IMPORTANT: Update library-owner.yml to use filename patterns:") + logger.log(" Instead of: 'com.example:*' use: '$filename*' or '$filename'") + return filename + } } diff --git a/cli/src/main/kotlin/com/grab/sizer/config/ProjectInput.kt b/cli/src/main/kotlin/com/grab/sizer/config/ProjectInput.kt index 0776de5..c4a7b63 100644 --- a/cli/src/main/kotlin/com/grab/sizer/config/ProjectInput.kt +++ b/cli/src/main/kotlin/com/grab/sizer/config/ProjectInput.kt @@ -47,6 +47,7 @@ data class ProjectInputConfig( val projectRoot: File, val r8MappingFile: File? = null, val ownerMappingFile: File? = null, + val libraryOwnerMappingFile: File? = null, ) { val modulesDirIsProjectRoot: Boolean get() = modulesDirectory.path.startsWith(projectRoot.path) @@ -64,6 +65,7 @@ class ProjectInputConfigDeserializer(vc: Class<*>? = null) : StdDeserializer SizerInputFile( + file = it, + tag = "androidx.security:security-crypto:1.1.0-alpha03" + ) + WORK_MULTIPROCESS_AAR -> SizerInputFile( + file = it, + tag = "androidx.work:work-multiprocess:2.8.0" + ) + else -> SizerInputFile(file = it, tag = it.nameWithoutExtension) + } } val expectingLibJars = libDir.getAll().filter { it.extension == EXT_JAR } .map { - SizerInputFile( - file = it, - tag = it.nameWithoutExtension - ) + when (it.name) { + SECURITY_CRYPTO_SOURCES_JAR -> SizerInputFile( + file = it, + tag = "androidx.security:security-crypto:1.1.0-alpha03" + ) + WORK_MULTIPROCESS_SOURCES_JAR -> SizerInputFile( + file = it, + tag = "androidx.work:work-multiprocess:2.8.0" + ) + else -> SizerInputFile(file = it, tag = it.nameWithoutExtension) + } } @@ -157,33 +171,39 @@ class TestingProject1 : FileSystem { private fun createLibDir(): FakeFile { return FakeFile(File("."), "gradle-cache", directory = true) { - addDirectory("androidx.security") { - addDirectory("security-crypto") { - addDirectory("1.1.0-alpha03") { - addDirectory("a96855861b33f9a46ca6a1556118ae592cad2014") { - addFile(SECURITY_CRYPTO_SOURCES_JAR) - } - addDirectory("b3c8960986915ab431476ae2072273adb4b83515") { - addFile(SECURITY_CRYPTO_POM) - } - addDirectory("f54110eab7610d08d7c41c594b3a248dac488e00") { - addFile(SECURITY_CRYPTO_AAR) + addDirectory("caches") { + addDirectory("modules-2") { + addDirectory("files-2.1") { + addDirectory("androidx.security") { + addDirectory("security-crypto") { + addDirectory("1.1.0-alpha03") { + addDirectory("sources-jar-hash") { + addFile(SECURITY_CRYPTO_SOURCES_JAR) + } + addDirectory("pom-file-hash") { + addFile(SECURITY_CRYPTO_POM) + } + addDirectory("aar-file-hash") { + addFile(SECURITY_CRYPTO_AAR) + } + } + } } - } - } - } - addDirectory("androidx.work") { - addDirectory("work-multiprocess") { - addDirectory("2.8.0") { - addDirectory("8547c508168f54ce7c2fa0c4b6c3fc8850d30f23") { - addFile(WORK_MULTIPROCESS_SOURCES_JAR) - } - addDirectory("90aacad73ba44fe05b25de0c5308160c703dba0b") { - addFile(WORK_MULTIPROCESS_AAR) - } - addDirectory("77a1c6094184a05d8718a77f004aaa75fd296b") { - addFile(WORK_MULTIPROCESS_POM) + addDirectory("androidx.work") { + addDirectory("work-multiprocess") { + addDirectory("2.8.0") { + addDirectory("multiprocess-sources-hash") { + addFile(WORK_MULTIPROCESS_SOURCES_JAR) + } + addDirectory("multiprocess-aar-hash") { + addFile(WORK_MULTIPROCESS_AAR) + } + addDirectory("multiprocess-pom-hash") { + addFile(WORK_MULTIPROCESS_POM) + } + } + } } } } diff --git a/sample/README.md b/sample/README.md index ad8774f..49d3194 100644 --- a/sample/README.md +++ b/sample/README.md @@ -72,20 +72,43 @@ For the CLI tool: - Markdown report: `[root-project]/build/app-sizer/[option]-report.md` - JSON report: `[root-project]/build/app-sizer/[option]-metrics.json` -## Module Ownership +## Ownership -The `module-owner.yml` file in the project root defines the ownership of different modules. This is used by App Sizer to attribute size contributions to different teams or components. +App Sizer tracks both module and library ownership to provide comprehensive team size attribution. + +### Module Ownership: `module-owner.yml` +Defines which teams own internal modules: ```yaml Platform: - app Team1: - android-module-level1 + - kotlin-multiplatform-module Team2: - - android-module-level2 + - sample-group:android-module-level2 - kotlin-module +``` + +### Library Ownership: `library-owner.yml` +Maps external libraries to teams using Maven coordinate patterns: +```yaml +Platform: + - androidx.core:* + - androidx.lifecycle:* + - androidx.appcompat:* +Team1: + - com.google.android.material:* + - androidx.constraintlayout:* + - androidx.navigation:* + +Team2: + - org.jetbrains.kotlin:* + - org.jetbrains.kotlinx:* + - com.google.guava:* ``` + ## Additional Resources - [App Sizer Documentation](../docs/index.md) diff --git a/sample/app-size-config/app-size-settings.yml b/sample/app-size-config/app-size-settings.yml index 043a305..07203fc 100644 --- a/sample/app-size-config/app-size-settings.yml +++ b/sample/app-size-config/app-size-settings.yml @@ -4,6 +4,7 @@ project-input: project-root-dir: "./" r8-mapping-file: "./app/build/outputs/mapping/proRelease/mapping.txt" owner-mapping-file: "./module-owner.yml" + library-owner-mapping-file: "./library-owner.yml" version: "1.0.1" large-file-threshold: 10 project-name: "sample" diff --git a/sample/app/build.gradle b/sample/app/build.gradle index cef7c27..cd69942 100644 --- a/sample/app/build.gradle +++ b/sample/app/build.gradle @@ -118,6 +118,7 @@ appSizer { enableMatchDebugVariant = true largeFileThreshold = 10 teamMappingFile = file("${rootProject.rootDir}/module-owner.yml") + libraryOwnershipFile = file("${rootProject.rootDir}/library-owner.yml") } metrics { /** Enable this block if you have an InfluxDb locally **/ diff --git a/sample/library-owner.yml b/sample/library-owner.yml new file mode 100644 index 0000000..d03225e --- /dev/null +++ b/sample/library-owner.yml @@ -0,0 +1,58 @@ +# Library Ownership Configuration for Sample Project +# This file maps external library dependencies to internal teams +# Teams match those defined in module-owner.yml: Platform, Team1, Team2 + +# Platform Team - Core Android libraries and foundational components +# Also owns the main 'app' module +Platform: + - androidx.core:* + - androidx.annotation:* + - androidx.collection:* + - androidx.arch.core:* + - androidx.profileinstaller:* + - androidx.startup:* + - androidx.tracing:* + - androidx.concurrent:* + - androidx.resourceinspection:* + - androidx.legacy:* + - androidx.lifecycle:* + - androidx.savedstate:* + - androidx.databinding:* + - androidx.activity:* + - androidx.fragment:* + - androidx.appcompat:* + - androidx.vectordrawable:* + - androidx.emoji2:* + - androidx.versionedparcelable:* + - androidx.window:* + +# Team1 - UI and Layout libraries +# Also owns android-module-level1 and kotlin-multiplatform-module +Team1: + - com.google.android.material:* + - androidx.constraintlayout:* + - androidx.recyclerview:* + - androidx.cardview:* + - androidx.coordinatorlayout:* + - androidx.viewpager:* + - androidx.viewpager2:* + - androidx.customview:* + - androidx.drawerlayout:* + - androidx.slidingpanelayout:* + - androidx.transition:* + - androidx.dynamicanimation:* + - androidx.interpolator:* + - androidx.navigation:* + +# Team2 - Kotlin and utility libraries +# Also owns sample-group:android-module-level2 and kotlin-module +Team2: + - org.jetbrains.kotlin:* + - org.jetbrains.kotlinx:* + - org.jetbrains:annotations:* + - com.google.guava:* + - androidx.cursoradapter:* + - androidx.loader:* + - androidx.localbroadcastmanager:* + - androidx.print:* + - androidx.documentfile:* \ No newline at end of file diff --git a/sizer-gradle-plugin/src/main/kotlin/com/grab/plugin/sizer/configuration/InputExtension.kt b/sizer-gradle-plugin/src/main/kotlin/com/grab/plugin/sizer/configuration/InputExtension.kt index 7df0cc2..15ab21f 100644 --- a/sizer-gradle-plugin/src/main/kotlin/com/grab/plugin/sizer/configuration/InputExtension.kt +++ b/sizer-gradle-plugin/src/main/kotlin/com/grab/plugin/sizer/configuration/InputExtension.kt @@ -40,6 +40,7 @@ private const val DEFAULT_LARGE_FILE = 10240L // 10kb open class InputExtension @Inject constructor(objects: ObjectFactory) { val apk: ApkGeneratorExtension = objects.newInstance(ApkGeneratorExtension::class.java, objects) val teamMappingFile: RegularFileProperty = objects.fileProperty() + val libraryOwnershipFile: RegularFileProperty = objects.fileProperty() var variantFilter: Action? = null var largeFileThreshold: Long = DEFAULT_LARGE_FILE var enableMatchDebugVariant = false diff --git a/sizer-gradle-plugin/src/main/kotlin/com/grab/plugin/sizer/tasks/AppSizeAnalysisTask.kt b/sizer-gradle-plugin/src/main/kotlin/com/grab/plugin/sizer/tasks/AppSizeAnalysisTask.kt index 504b2db..588a3dc 100644 --- a/sizer-gradle-plugin/src/main/kotlin/com/grab/plugin/sizer/tasks/AppSizeAnalysisTask.kt +++ b/sizer-gradle-plugin/src/main/kotlin/com/grab/plugin/sizer/tasks/AppSizeAnalysisTask.kt @@ -83,6 +83,11 @@ internal abstract class AppSizeAnalysisTask : DefaultTask() { @get:PathSensitive(PathSensitivity.NONE) abstract val teamMappingFile: RegularFileProperty + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.NONE) + abstract val libraryOwnershipFile: RegularFileProperty + @get:InputFile @get:Optional @get:PathSensitive(PathSensitivity.NONE) @@ -142,7 +147,8 @@ internal abstract class AppSizeAnalysisTask : DefaultTask() { r8MappingFile = r8MappingFile.orNull?.asFile, apksDirectory = apksDirectory, largeFileThreshold = largeFileThreshold.get(), - teamMappingFile = if (teamMappingFile.isPresent) teamMappingFile.asFile.get() else null + teamMappingFile = if (teamMappingFile.isPresent) teamMappingFile.asFile.get() else null, + libraryOwnershipFile = if (libraryOwnershipFile.isPresent) libraryOwnershipFile.asFile.get() else null // NEW ) private fun createOutputProvider( @@ -184,6 +190,9 @@ internal abstract class AppSizeAnalysisTask : DefaultTask() { if (pluginExtension.input.teamMappingFile.isPresent) { this.teamMappingFile.set(pluginExtension.input.teamMappingFile) } + if (pluginExtension.input.libraryOwnershipFile.isPresent) { + this.libraryOwnershipFile.set(pluginExtension.input.libraryOwnershipFile) + } this.largeFileThreshold.set(pluginExtension.input.largeFileThreshold) if (variant.mappingFileProvider.isPresent && variant.buildType.isMinifyEnabled) { diff --git a/sizer-gradle-plugin/src/main/kotlin/com/grab/plugin/sizer/utils/PluginInputProvider.kt b/sizer-gradle-plugin/src/main/kotlin/com/grab/plugin/sizer/utils/PluginInputProvider.kt index 8f05bfa..15d9897 100644 --- a/sizer-gradle-plugin/src/main/kotlin/com/grab/plugin/sizer/utils/PluginInputProvider.kt +++ b/sizer-gradle-plugin/src/main/kotlin/com/grab/plugin/sizer/utils/PluginInputProvider.kt @@ -41,6 +41,7 @@ class PluginInputProvider( private val largeFileThreshold: Long, private val teamMappingFile: File? = null, private val r8MappingFile: File? = null, + private val libraryOwnershipFile: File? = null, ) : InputProvider { override fun provideModuleAar(): Sequence = archiveDependencyStore.getModuleDependency() @@ -86,6 +87,8 @@ class PluginInputProvider( override fun provideTeamMappingFile(): File? = teamMappingFile + override fun provideLibraryOwnershipFile(): File? = libraryOwnershipFile + override fun provideLargeFileThreshold(): Long = largeFileThreshold }