Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the concept of roots #55

Merged
merged 5 commits into from
Feb 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ to be using. In the ideal world, this would never happen, because you are enforc
is using semver correctly. But in the real world, you have a runtime error on your hands and you won't catch it until
you exercise a specific code path in production or - hopefully - tests.

This tool can detect _some_ binary incompatibilities at build time. Specifically, it can report
This tool can detect certain binary incompatibilities at build time. Specifically, it will report

* Missing classes
* Duplicate classes
Expand All @@ -35,7 +35,9 @@ Conceptually, the inputs for running the tool are _application classes_ and _pla
classes are the classes that need to be validated, and the platform APIs are the APIs provided by the runtime
environment of the application, e.g. the JVM or the Android SDK.

Typically, the application classes are the classes compiled directly by the project and its runtime dependencies.
Typically, the application classes are the classes compiled directly by the project and the classes from the project's
runtime dependencies that they reference.

The platform APIs can be specified by the JVM, a set of dependencies, or serialized type descriptors.

## Basic setup
Expand All @@ -50,7 +52,12 @@ plugins {

## Application classes

By default, the application classes are the classes from the main source set and the runtime dependencies of the project.
By default, the application classes are selected from the main source set and the runtime dependencies of the project.
The classes compiled from the main source set and subproject dependencies are treated as _roots_. The roots and all
classes from the external runtime dependencies reachable from the roots then form the set of application classes.

The concept of roots makes it easier to filter out unused classes from third-party dependencies, which would otherwise
produce noise.

You can customize this behavior, e.g. change the Gradle configuration that describes the dependencies.

Expand All @@ -65,6 +72,33 @@ expediter {

For example, in an Android project, you will want to use a different configuration, such as `productionReleaseRuntime`.

You can also customize how the roots are chosen. This is the implicit default setup, where the roots are the classes
compiled from the current project and other subprojects of the same projects.

```kotlin
expediter {
application {
roots {
project()
}
}
}
```

This is a different setup, where the roots are all classes compiled from the current project and all classes in its
runtime dependencies. Beware that with this setup, there will likely be a lot of noise from unused classes providig
optional functionality.

```kotlin
expediter {
application {
roots {
all()
}
}
}
```

## Platform APIs

Platform APIs can be provided by the JVM or specified as a set of dependencies or published type descriptors in the
Expand Down
11 changes: 7 additions & 4 deletions core/src/main/kotlin/com/toasttab/expediter/Expediter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.toasttab.expediter.ignore.Ignore
import com.toasttab.expediter.issue.Issue
import com.toasttab.expediter.provider.ApplicationTypesProvider
import com.toasttab.expediter.provider.PlatformTypeProvider
import com.toasttab.expediter.roots.RootSelector
import com.toasttab.expediter.types.ApplicationType
import com.toasttab.expediter.types.ApplicationTypeContainer
import com.toasttab.expediter.types.InspectedTypes
Expand All @@ -40,13 +41,15 @@ import protokt.v1.toasttab.expediter.v1.TypeFlavor
class Expediter(
private val ignore: Ignore,
private val appTypes: ApplicationTypeContainer,
private val platformTypeProvider: PlatformTypeProvider
private val platformTypeProvider: PlatformTypeProvider,
private val rootSelector: RootSelector
) {
constructor(
ignore: Ignore,
appTypes: ApplicationTypesProvider,
platformTypeProvider: PlatformTypeProvider
) : this(ignore, ApplicationTypeContainer.create(appTypes.types()), platformTypeProvider)
platformTypeProvider: PlatformTypeProvider,
rootSelector: RootSelector = RootSelector.All
) : this(ignore, ApplicationTypeContainer.create(appTypes.types()), platformTypeProvider, rootSelector)

private val inspectedTypes: InspectedTypes by lazy {
InspectedTypes(appTypes, platformTypeProvider)
Expand Down Expand Up @@ -119,7 +122,7 @@ class Expediter(

fun findIssues(): Set<Issue> {
return (
inspectedTypes.classes.flatMap { appType ->
inspectedTypes.reachableTypes(rootSelector).flatMap { appType ->
findIssues(appType)
} + inspectedTypes.duplicateTypes
).filter { !ignore.ignore(it) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package com.toasttab.expediter.parser

import com.toasttab.expediter.types.ApplicationType
import com.toasttab.expediter.types.ClassfileSource
import com.toasttab.expediter.types.FieldAccessType
import com.toasttab.expediter.types.MemberAccess
import com.toasttab.expediter.types.MemberSymbolicReference
Expand All @@ -35,7 +36,7 @@ import protokt.v1.toasttab.expediter.v1.TypeDescriptor
import java.io.InputStream

object TypeParsers {
fun applicationType(stream: InputStream, source: String) = ApplicationTypeParser(source).apply {
fun applicationType(stream: InputStream, source: ClassfileSource) = ApplicationTypeParser(source).apply {
ClassReader(stream).accept(this, SKIP_DEBUG)
}.get()

Expand All @@ -44,7 +45,7 @@ object TypeParsers {
}.get()
}

private class ApplicationTypeParser(private val source: String) : ClassVisitor(ASM9, TypeDescriptorParser()) {
private class ApplicationTypeParser(private val source: ClassfileSource) : ClassVisitor(ASM9, TypeDescriptorParser()) {
private val refs: MutableSet<MemberAccess<*>> = hashSetOf()
private val referencedTypes: MutableSet<String> = hashSetOf()

Expand Down Expand Up @@ -99,6 +100,7 @@ private class ApplicationTypeParser(private val source: String) : ClassVisitor(A
)

referencedTypes.addAll(SignatureParser.parseMethod(descriptor).referencedTypes())
referencedTypes.add(owner)
}

override fun visitFieldInsn(opcode: Int, owner: String, name: String, descriptor: String) {
Expand All @@ -118,6 +120,7 @@ private class ApplicationTypeParser(private val source: String) : ClassVisitor(A
)

referencedTypes.addAll(SignatureParser.parseType(descriptor).referencedTypes())
referencedTypes.add(owner)
}

override fun visitInvokeDynamicInsn(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ package com.toasttab.expediter.provider

import com.toasttab.expediter.parser.TypeParsers
import com.toasttab.expediter.scanner.ClasspathScanner
import java.io.File
import com.toasttab.expediter.types.ClassfileSource

class ClasspathApplicationTypesProvider(
elements: Iterable<File>
elements: Iterable<ClassfileSource>
) : ApplicationTypesProvider {
private val scanner = ClasspathScanner(elements)

Expand Down
11 changes: 11 additions & 0 deletions core/src/main/kotlin/com/toasttab/expediter/roots/RootSelector.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.toasttab.expediter.roots

import com.toasttab.expediter.types.ApplicationType

interface RootSelector {
fun isRoot(type: ApplicationType): Boolean

object All : RootSelector {
override fun isRoot(type: ApplicationType) = true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,24 @@

package com.toasttab.expediter.scanner

import java.io.File
import com.toasttab.expediter.types.ClassfileSource
import java.io.InputStream
import java.lang.Exception
import java.lang.RuntimeException
import java.util.jar.JarInputStream
import java.util.zip.ZipFile

class ClasspathScanner(
private val elements: Iterable<File>
private val elements: Iterable<ClassfileSource>
) {
fun <T> scan(parse: (stream: InputStream, source: String) -> T): List<T> = elements.flatMap { types(it, parse) }
fun <T> scan(parse: (stream: InputStream, source: ClassfileSource) -> T): List<T> = elements.flatMap { types(it, parse) }

private fun isClassFile(name: String) = name.endsWith(".class") &&
!name.startsWith("META-INF/versions") && // mrjars not supported yet
!name.endsWith("package-info.class") &&
!name.endsWith("module-info.class")

private fun <T> scanJarStream(stream: JarInputStream, source: String, parse: (stream: InputStream, source: String) -> T) = generateSequence { stream.nextJarEntry }
private fun <T> scanJarStream(stream: JarInputStream, source: ClassfileSource, parse: (stream: InputStream, source: ClassfileSource) -> T) = generateSequence { stream.nextJarEntry }
.filter { isClassFile(it.name) }
.map {
try { parse(stream, source) } catch (e: Exception) {
Expand All @@ -41,34 +41,34 @@ class ClasspathScanner(
}
.toList()

fun <T> scanJar(path: File, parse: (stream: InputStream, source: String) -> T): List<T> = path.inputStream().use {
scanJarStream(JarInputStream(it), path.name, parse)
fun <T> scanJar(source: ClassfileSource, parse: (stream: InputStream, source: ClassfileSource) -> T): List<T> = source.file.inputStream().use {
scanJarStream(JarInputStream(it), source, parse)
}

fun <T> scanAar(path: File, parse: (stream: InputStream, source: String) -> T): List<T> =
ZipFile(path).use { aar ->
fun <T> scanAar(source: ClassfileSource, parse: (stream: InputStream, source: ClassfileSource) -> T): List<T> =
ZipFile(source.file).use { aar ->
when (val classesEntry = aar.getEntry("classes.jar")) {
null -> emptyList()
else -> {
JarInputStream(aar.getInputStream(classesEntry)).use {
scanJarStream(it, path.name, parse)
scanJarStream(it, source, parse)
}
}
}
}

fun <T> scanClassDir(path: File, parse: (stream: InputStream, source: String) -> T): List<T> =
path.walkTopDown().filter { isClassFile(it.name) }.map {
fun <T> scanClassDir(source: ClassfileSource, parse: (stream: InputStream, source: ClassfileSource) -> T): List<T> =
source.file.walkTopDown().filter { isClassFile(it.name) }.map {
it.inputStream().use { classStream ->
parse(classStream, path.name)
parse(classStream, source)
}
}.toList()

private fun <T> types(path: File, parse: (stream: InputStream, source: String) -> T) =
private fun <T> types(source: ClassfileSource, parse: (stream: InputStream, source: ClassfileSource) -> T) =
when {
path.isDirectory -> scanClassDir(path, parse)
path.name.endsWith(".jar") -> scanJar(path, parse)
path.name.endsWith(".aar") -> scanAar(path, parse)
source.file.isDirectory -> scanClassDir(source, parse)
source.file.name.endsWith(".jar") -> scanJar(source, parse)
source.file.name.endsWith(".aar") -> scanAar(source, parse)
else -> emptyList()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import com.toasttab.expediter.issue.Issue
import com.toasttab.expediter.parser.SignatureParser
import com.toasttab.expediter.parser.TypeSignature
import com.toasttab.expediter.provider.PlatformTypeProvider
import com.toasttab.expediter.roots.RootSelector
import java.util.LinkedList
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap

Expand All @@ -28,7 +30,7 @@ class ApplicationTypeContainer(
) {
companion object {
fun create(all: List<ApplicationType>): ApplicationTypeContainer {
val duplicates = HashMap<String, MutableList<String>>()
val duplicates = HashMap<String, MutableList<ClassfileSource>>()
val types = HashMap<String, ApplicationType>()

for (type in all) {
Expand All @@ -48,7 +50,7 @@ class ApplicationTypeContainer(
}
}

return ApplicationTypeContainer(types, duplicates.map { (k, v) -> Issue.DuplicateType(k, v) })
return ApplicationTypeContainer(types, duplicates.map { (k, v) -> Issue.DuplicateType(k, v.map { it.file.name }) })
}
}
}
Expand All @@ -75,7 +77,7 @@ class InspectedTypes(
}
}

private fun traverse(type: Type): TypeHierarchy {
private fun hierarchy(type: Type): TypeHierarchy {
val cached = hierarchyCache[type.name]

if (cached == null) {
Expand All @@ -92,7 +94,7 @@ class InspectedTypes(
if (superType == null) {
superTypes.add(OptionalType.MissingType(signature.name))
} else {
val hierarchy = traverse(superType)
val hierarchy = hierarchy(superType)
superTypes.add(OptionalType.PresentType(superType))
superTypes.addAll(hierarchy.superTypes)
}
Expand All @@ -112,9 +114,30 @@ class InspectedTypes(
}

fun resolveHierarchy(type: Type): ResolvedTypeHierarchy {
return traverse(type).resolve()
return hierarchy(type).resolve()
}

val classes: Collection<ApplicationType> get() = appTypes.appTypes.values

fun reachableTypes(rootSelector: RootSelector): Collection<ApplicationType> {
if (rootSelector == RootSelector.All) {
return appTypes.appTypes.values
} else {
val reachable = hashMapOf<String, ApplicationType>()
val todo = LinkedList(appTypes.appTypes.values.filter(rootSelector::isRoot))

while (todo.isNotEmpty()) {
val next = todo.remove()

if (reachable.put(next.name, next) == null) {
todo.addAll(next.referencedTypes.mapNotNull(appTypes.appTypes::get))
todo.addAll(hierarchy(next).presentSuperTypes().filterIsInstance<ApplicationType>())
}
}

return reachable.values
}
}

val duplicateTypes: Collection<Issue.DuplicateType> get() = appTypes.duplicates
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,20 @@ class TypeHierarchy(
val superTypes: Set<OptionalType>
) {
fun resolve(): ResolvedTypeHierarchy {
val missing = superTypes.filterIsInstance<OptionalType.MissingType>()
val missing = missingSuperTypes()
return if (missing.isNotEmpty()) {
ResolvedTypeHierarchy.IncompleteTypeHierarchy(type, missing.toSet())
} else {
ResolvedTypeHierarchy.CompleteTypeHierarchy(
type,
superTypes.asSequence().filterIsInstance<OptionalType.PresentType>().map { it.type }
presentSuperTypes()
)
}
}

fun missingSuperTypes() = superTypes.filterIsInstance<OptionalType.MissingType>()

fun presentSuperTypes() = superTypes.filterIsInstance<OptionalType.PresentType>().map { it.type }
}

/**
Expand Down Expand Up @@ -67,7 +71,7 @@ sealed interface ResolvedTypeHierarchy : OptionalResolvedTypeHierarchy, Identifi
override val name get() = type.name

class IncompleteTypeHierarchy(override val type: Type, val missingType: Set<OptionalType.MissingType>) : ResolvedTypeHierarchy
class CompleteTypeHierarchy(override val type: Type, val superTypes: Sequence<Type>) : ResolvedTypeHierarchy {
class CompleteTypeHierarchy(override val type: Type, val superTypes: Iterable<Type>) : ResolvedTypeHierarchy {
val allTypes: Sequence<Type> get() = sequenceOf(type) + superTypes
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.toasttab.expediter.types

import java.io.File

/**
* Reference to a class file with additional metadata describing where the class file comes from.
*/
data class ClassfileSource(
val file: File,
val type: ClassfileSourceType,
val name: String
)

enum class ClassfileSourceType {
/** unmanaged by the build system, e.g. a raw jar file */
UNKNOWN,

/** compiled from source in the current project/subproject */
SOURCE_SET,

/** a different subproject within the same project */
SUBPROJECT_DEPENDENCY,

/** an external dependency managed by the build system */
EXTERNAL_DEPENDENCY
}
2 changes: 1 addition & 1 deletion model/src/main/kotlin/com/toasttab/expediter/types/Type.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class ApplicationType(
override val descriptor: TypeDescriptor,
val memberAccess: Set<MemberAccess<*>>,
val referencedTypes: Set<String>,
val source: String
val source: ClassfileSource
) : Type {
override fun toString() = "ApplicationType[$name]"
}
Expand Down
Loading
Loading