Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import java.nio.file.Path
import kotlin.io.path.Path
import kotlin.io.path.absolutePathString
import kotlin.io.path.createDirectories
import kotlin.io.path.div
import kotlin.io.path.exists
import kotlin.io.path.extension
import kotlin.io.path.isDirectory
Expand All @@ -20,12 +19,25 @@ import kotlin.io.path.readText
import kotlin.io.path.walk
import kotlin.io.path.writeText

fun parseResolutionTable(reportDir: Path): Map<String, Path> {
val json = Json { ignoreUnknownKeys = true }
val table = HashMap<String, Path>()
reportDir.walk().filter { it.extension == "json" && it.name.startsWith("table-") }.forEach { f ->
json.decodeFromString<List<GradleProjectResolver.TableEntry>>(f.readText()).forEach { e ->
table.putIfAbsent(e.coord, Path(e.file))
}
}
return table
}

class GradleProjectResolver(
private val resolverDir: Path,
override val projectSourceRoot: Path
) : ProjectResolver {
private val resolvedModules = mutableListOf<ProjectModuleClasses>()
private val resolvedProjectDependencies = mutableListOf<Path>()
private val resolvedProjectDependencies = mutableListOf<ResolvedDependency>()

private var resolutionTable: Map<String, Path> = emptyMap()

private val json = Json { ignoreUnknownKeys = true }

Expand Down Expand Up @@ -107,6 +119,23 @@ class GradleProjectResolver(
}
}

private val tableResolverInitScript: Path by lazy {
resolverDir.resolve("resolution-table.gradle").apply { writeText(GRADLE_RESOLUTION_TABLE_INIT_SCRIPT) }
}

private fun resolveResolutionTable() {
val outDir = resolverDir.resolve("table-out").createDirectories()
val gradleExecutable = resolveGradleExecutable(projectSourceRoot)
val args = listOf(gradleExecutable) + gradleBuildFlags + listOf(
"--init-script", tableResolverInitScript.absolutePathString(),
"-D$TABLE_REPORT_DIR_PROPERTY=${outDir.absolutePathString()}",
RESOLVE_TABLE_TASK,
)
val status = ProjectResolver.runCommand(projectSourceRoot, args, javaToolchain)
if (status != 0) logger.warn { "Gradle resolution-table returned $status for: $projectSourceRoot" }
resolutionTable = parseResolutionTable(outDir)
}

private fun resolveDependencies(): Boolean {
val depGraphOutFolder = resolverDir.resolve("dg-out").createDirectories()

Expand All @@ -120,6 +149,8 @@ class GradleProjectResolver(
return false
}

resolveResolutionTable()
logger.info { "Gradle resolution table size: ${resolutionTable.size} for: $projectSourceRoot" }
resolveDependenciesFromGraph(depGraphOutFolder)

return true
Expand All @@ -134,7 +165,7 @@ class GradleProjectResolver(
dependencyResolver.addDependencies(deps)
}

resolvedProjectDependencies += dependencyResolver.resolveDependenciesJars()
resolvedProjectDependencies += dependencyResolver.resolveDependencies(resolutionTable)
}

private class GradleDependencyResolver {
Expand All @@ -154,30 +185,14 @@ class GradleProjectResolver(
}
}

fun resolveDependenciesJars(): List<Path> {
val allDependenciesInfo = dependenciesInfo.entries.sortedBy { it.key }

val resolvedDirectDependencies = allDependenciesInfo
.filter { it.key in directDependencies }
.mapNotNull { resolveJarPath(it.value) }

val resolvedIndirectDependencies = allDependenciesInfo
.filter { it.key !in directDependencies }
.mapNotNull { resolveJarPath(it.value) }

return resolvedDirectDependencies + resolvedIndirectDependencies
}

private fun resolveJarPath(dependency: GradleDependencyInfo): Path? {
val gradlePath = gradleLocalRepoPath.resolve(dependency.gradleArtifactDir)
if (gradlePath.isDirectory()) {
gradlePath.walk().firstOrNull { it.name == dependency.artifactJarName }?.let { return it }
}

val mavenPath = mavenLocalRepoPath.resolve(dependency.mavenArtifactDir).resolve(dependency.artifactJarName)
if (mavenPath.isRegularFile()) return mavenPath

return null
fun resolveDependencies(table: Map<String, Path>): List<ResolvedDependency> {
val all = dependenciesInfo.entries.sortedBy { it.key }
val direct = all.filter { it.key in directDependencies }
val indirect = all.filterNot { it.key in directDependencies }
fun resolve(d: GradleDependencyInfo): ResolvedDependency? =
table["${d.groupId}:${d.artifactId}:${d.version}"]
?.let { ResolvedDependency(it, mavenPurl(d.groupId, d.artifactId, d.version)) }
return (direct + indirect).mapNotNull { resolve(it.value) }
}
}

Expand All @@ -187,6 +202,9 @@ class GradleProjectResolver(
val classDirs: List<String> = emptyList()
)

@Serializable
data class TableEntry(val coord: String, val file: String)

@Serializable
data class GradleDependencies(
val manifests: Map<String, GradleDependenciesManifest>? = null
Expand Down Expand Up @@ -228,25 +246,9 @@ class GradleProjectResolver(
}
}

data class GradleDependencyInfo(
val groupId: String,
val artifactId: String,
val version: String,
) {
val artifactJarName: String by lazy { "${artifactId}-${version}.jar" }
val mavenArtifactDir: List<String> by lazy { groupId.split(".") + listOf(artifactId, version) }
val gradleArtifactDir: List<String> by lazy { listOf(groupId, artifactId, version) }
}
data class GradleDependencyInfo(val groupId: String, val artifactId: String, val version: String)

companion object {
private val mavenLocalRepoPath by lazy {
Path(System.getProperty("user.home")) / ".m2" / "repository"
}

private val gradleLocalRepoPath by lazy {
Path(System.getProperty("user.home")) / ".gradle" / "caches" / "modules-2" / "files-2.1"
}

private const val GRADLE_SETTINGS_FILE = "settings.gradle"
private const val GRADLE_SETTINGS_KTS_FILE = "$GRADLE_SETTINGS_FILE.kts"
private const val GRADLE_BUILD_FILE = "build.gradle"
Expand Down Expand Up @@ -293,6 +295,10 @@ class GradleProjectResolver(

private const val CLASSES_REPORT_DIR_PROPERTY = "OPENTAINT_CLASSES_REPORT_DIR"

private const val RESOLVE_TABLE_TASK = "opentaintResolveTable"

private const val TABLE_REPORT_DIR_PROPERTY = "opentaint.table.dir"

private val GRADLE_CLASSES_INIT_SCRIPT = """
import groovy.json.JsonOutput

Expand Down Expand Up @@ -375,6 +381,43 @@ class GradleProjectResolver(
apply plugin: GitHubDependencyGraphPlugin
""".trimIndent()

private val GRADLE_RESOLUTION_TABLE_INIT_SCRIPT = """
import groovy.json.JsonOutput
import org.gradle.api.artifacts.component.ModuleComponentIdentifier

def reportDir = new File(System.getProperty("$TABLE_REPORT_DIR_PROPERTY"))
reportDir.mkdirs()

gradle.rootProject { root -> root.tasks.register("$RESOLVE_TABLE_TASK") }

allprojects { p ->
p.afterEvaluate {
def reportTask = p.tasks.register("opentaintReportTable") {
doLast {
def seen = [] as Set
def out = []
p.configurations.findAll { it.canBeResolved }.each { cfg ->
try {
cfg.incoming.artifactView { v -> v.lenient = true }.artifacts.each { art ->
def id = art.id.componentIdentifier
if (!(id instanceof ModuleComponentIdentifier)) return
def coord = id.group + ":" + id.module + ":" + id.version
if (!seen.add(coord)) return
out.add([coord: coord, file: art.file.absolutePath])
}
} catch (Exception e) {
println "opentaint: failed to resolve " + cfg.name + " in " + p.path + ": " + e
}
}
def name = "table-" + p.path.replace(":", "_") + ".json"
new File(reportDir, name).text = JsonOutput.toJson(out)
}
}
p.rootProject.tasks.named("$RESOLVE_TABLE_TASK").configure { dependsOn reportTask }
}
}
""".trimIndent()

private fun resolveGradleDependencyCmdArgs(workDir: Path, initScript: Path, reportDir: Path): List<String> =
listOf(
"-Dorg.gradle.configureondemand=false",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class MavenProjectResolver(
private val resolverDir: Path,
override val projectSourceRoot: Path
) : ProjectResolver {
private val resolvedProjectDependencies = mutableListOf<Path>()
private val resolvedProjectDependencies = mutableListOf<ResolvedDependency>()
private val resolvedModules = mutableListOf<ProjectModuleClasses>()

private var executableFound = true
Expand Down Expand Up @@ -129,7 +129,7 @@ class MavenProjectResolver(
dependencyResolver.addDependencies(deps)
}

resolvedProjectDependencies += dependencyResolver.resolveDependenciesJars()
resolvedProjectDependencies += dependencyResolver.resolveDependencies()
}

class MavenDependencyGraphResolver {
Expand All @@ -153,11 +153,13 @@ class MavenProjectResolver(
}
}

fun resolveDependenciesJars(): List<Path> {
fun resolveDependencies(
resolvePath: (MavenArtifact) -> Path? = { mavenLocalRepoPath.resolve(it.artifactJarPath).takeIf { p -> p.exists() } },
): List<ResolvedDependency> {
val buildArtifactsNames = buildArtifacts.mapTo(mutableSetOf()) { artifacts.getValue(it).artifactName }
return artifacts.values
.filter { artifact -> artifact.artifactName !in buildArtifactsNames }
.mapNotNull { artifact -> mavenLocalRepoPath.resolve(artifact.artifactJarPath).takeIf { it.exists() } }
.filter { it.artifactName !in buildArtifactsNames }
.mapNotNull { a -> resolvePath(a)?.let { ResolvedDependency(it, mavenPurl(a.groupId, a.artifactId, a.version)) } }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ class PortableProjectCreator(
sourceRoot = project.sourceRoot?.let { copySources(ctx, project, it) },
javaToolchain = project.javaToolchain?.let { copyToolchain(ctx, it) },
modules = project.modules.map { create(ctx, project, it) },
dependencies = project.dependencies.map { copyDependency(ctx, it) },
dependencies = project.dependencies.map { it.copy(path = copyDependency(ctx, it.path)) },
subProjects = project.subProjects.map { create(ctx, it) }
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class ProjectFromCPResolver {

val resolvedCp = projectCp.cp.flatMap { resolveProjectCp(it, resolverWorkDir) }
val module = ProjectModuleClasses(rootDir, projectCp.pkg, resolvedCp)
return JavaProject(rootDir, projectCp.toolchain, listOf(module), projectCp.dependency)
return JavaProject(rootDir, projectCp.toolchain, listOf(module), projectCp.dependency.map { ResolvedDependency(it) })
}

private var unpackId = 0
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.opentaint.project

import kotlin.io.path.Path
import kotlin.io.path.createDirectories
import kotlin.io.path.writeText
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import java.nio.file.Files

class GradleResolutionTableTest {
@Test
fun `folds per-project reports into one coordinate-keyed table`() {
val dir = Files.createTempDirectory("table").also { it.createDirectories() }
dir.resolve("table-_os-v2.json").writeText(
"""[{"coord":"org.opensearch.client:opensearch-rest-client:2.18.0","file":"/g/os2/opensearch-rest-client-2.18.0.jar"}]""")
dir.resolve("table-_os-v3.json").writeText(
"""[{"coord":"org.opensearch.client:opensearch-rest-client:3.5.0","file":"/g/os3/opensearch-rest-client-3.5.0.jar"}]""")

val table = parseResolutionTable(dir)

assertEquals(Path("/g/os2/opensearch-rest-client-2.18.0.jar"),
table["org.opensearch.client:opensearch-rest-client:2.18.0"])
assertEquals(Path("/g/os3/opensearch-rest-client-3.5.0.jar"),
table["org.opensearch.client:opensearch-rest-client:3.5.0"])
assertNull(table["org.opensearch.client:opensearch-rest-client:1.0.0"])
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package org.opentaint.project

import java.nio.file.Files
import kotlin.io.path.createDirectories
import kotlin.io.path.deleteRecursively
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.exists
import kotlin.io.path.isRegularFile
import kotlin.io.path.pathString
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.test.fail

/**
* Runs the real autobuilder Gradle pipeline against a small project with a versioned Maven Central
* dependency and checks the emitted model. Shells out to Gradle and needs network access (Maven
* Central + the Gradle Plugin Portal).
*/
@OptIn(ExperimentalPathApi::class)
class GradleTaggedResolutionIT {
@Test
fun `gradle project dependencies come out tagged with coordinates and resolved to real files`() {
val fixtureResource = javaClass.classLoader.getResource("gradle-fixture-project")
?: fail("gradle-fixture-project test resource not found on classpath")

val workRoot = Files.createTempDirectory("gradle-tagged-resolution-it")
try {
val projectDir = workRoot.resolve("project").createDirectories()
java.nio.file.Paths.get(fixtureResource.toURI()).copyDirRecursivelyTo(projectDir)

val buildDir = workRoot.resolve("build").createDirectories()
val resolved = ProjectResolver.resolveProject(projectDir, buildDir)
?: fail("Autobuilder failed to resolve the fixture Gradle project")

val modelPath = workRoot.resolve("model.yaml")
resolved.dump(modelPath)

// Re-parse from the emitted YAML, like a real consumer would.
val loaded = JavaProject.load(modelPath)

assertTrue(loaded.dependencies.isNotEmpty(), "Expected at least one resolved dependency")

// Invariant 1: every dependency is tagged with a purl, not a bare path.
loaded.dependencies.forEach { dep ->
assertNotNull(dep.purl, "Untagged dependency (missing purl): $dep")
}

// Invariant 2: every dependency path resolves to a real file on disk.
loaded.dependencies.forEach { dep ->
assertTrue(
dep.path.exists() && dep.path.isRegularFile(),
"Dependency path does not exist on disk: ${dep.path}"
)
}

// Invariant 3: no dependency path leaks a project/module build output directory.
loaded.dependencies.forEach { dep ->
assertFalse(
dep.path.pathString.contains("build/classes"),
"Dependency path leaks a project class directory: ${dep.path}"
)
}

// Invariant 4: the resolved version reflects what the build declares, not a stray cached one.
val commonsLang3 = loaded.dependencies.singleOrNull {
it.purl == "pkg:maven/org.apache.commons/commons-lang3@3.14.0"
} ?: fail("Expected a resolved commons-lang3 dependency, got: ${loaded.dependencies}")
assertEquals("pkg:maven/org.apache.commons/commons-lang3@3.14.0", commonsLang3.purl)
} finally {
workRoot.deleteRecursively()
}
}
}
Loading
Loading