From 6c3c07c2f2f9cea24c47973b87fc2d156c294789 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Tue, 27 Oct 2026 17:45:49 +0100 Subject: [PATCH] fix(manifest): emit Kotlin, Android and sbt base-dir sources Reachability treats a JVM project with no source or class files as having no code, so code the facts producers fail to report is silently skipped. - Gradle: Kotlin source sets and class dirs of JVM and Android Kotlin compilations (Kotlin Multiplatform's commonMain/jvmMain included), and Android source sets plus the javac and Kotlin compile tasks' outputs. AGP's built-in Kotlin leaves the compilations' class dirs empty, so those come from the compile tasks. - sbt: sources sbt compiles from a project's base dir (`sourcesInBase`), taken from `unmanagedSources` and emitted as files, since sbt includes them non-recursively and the base dir as a root would pull in nested subprojects. --- CHANGELOG.md | 5 ++ .../manifest/scripts/socket-facts.init.gradle | 57 ++++++++++++++++++- .../scripts/socket-facts.plugin.scala | 34 +++++++++-- .../test/sbt-compat/project/Base.scala | 1 + .../scripts/test/sbt-compat/smoke-test.sh | 13 +++-- 5 files changed, 100 insertions(+), 10 deletions(-) create mode 100644 src/commands/manifest/scripts/test/sbt-compat/project/Base.scala diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fd5eb7d1..c4e7a0a50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Fixed +- Reachability analysis of Gradle and sbt builds now sees the code of Kotlin Multiplatform and Android modules, and sbt sources placed directly in a project's directory. + ## [1.11.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.11.1) - 2026-10-27 ### Added diff --git a/src/commands/manifest/scripts/socket-facts.init.gradle b/src/commands/manifest/scripts/socket-facts.init.gradle index 48272bde3..d10c68384 100644 --- a/src/commands/manifest/scripts/socket-facts.init.gradle +++ b/src/commands/manifest/scripts/socket-facts.init.gradle @@ -95,8 +95,8 @@ gradle.projectsEvaluated { g -> // collector). The project maps above stay populated so a KEPT project depending on it // still recognizes it as a first-party module, and its paths are kept so it is analysed with its code. def excluded = g.ext.socketIsExcluded.call(rel(p.projectDir)) - // Guarded: projects without the java `sourceSets` convention (pure aggregators, AGP-only - // Android modules) contribute empty lists rather than failing. + // Guarded: projects without the java `sourceSets` convention (pure aggregators) contribute empty + // lists rather than failing. Kotlin and Android code lives outside it (see below). // ALL source sets are emitted (not just main/test) so custom ones (integrationTest, // functionalTest, ...) reach the analysis; source-file-level exclusion is coana's job. // `classesDirs` (Gradle 4+) falls back to the legacy single `classesDir` on older Gradle. @@ -116,6 +116,59 @@ gradle.projectsEvaluated { g -> } } } catch (Exception ignore) {} + // Kotlin compilations for the JVM (kotlin-jvm, KMP `jvm()`, Android): Kotlin source sets, shared + // ones like commonMain included, are not java source sets. Untyped: KGP is not on this classpath. + def kotlin = p.extensions.findByName('kotlin') + if (kotlin != null) { + try { + (kotlin.hasProperty('targets') ? kotlin.targets : [kotlin.target]).each { t -> + try { + if (t.platformType.name() in ['jvm', 'androidJvm']) { + t.compilations.each { c -> + c.allKotlinSourceSets.each { ks -> + sources.addAll(ks.kotlin.srcDirs*.absolutePath) + sources.addAll(ks.resources.srcDirs*.absolutePath) + } + targets.addAll(c.output.classesDirs*.absolutePath) + } + } + } catch (Exception ignore) {} + } + } catch (Exception ignore) {} + } + // AGP keeps its source sets in the `android` extension and leaves the java ones empty. + def android = p.extensions.findByName('android') + if (android != null) { + try { + android.sourceSets.each { ss -> + try { + sources.addAll(ss.java.srcDirs*.absolutePath) + sources.addAll(ss.resources.srcDirs*.absolutePath) + // Last: older AGP has no `kotlin`. + sources.addAll(ss.kotlin.srcDirs*.absolutePath) + } catch (Exception ignore) {} + } + } catch (Exception ignore) {} + // Class dirs come from the compile tasks: AGP's built-in Kotlin (AGP 9) leaves the Kotlin + // compilations' classesDirs empty. + def compileTypes = [JavaCompile] + if (kotlin != null) { + try { + compileTypes << Class.forName('org.jetbrains.kotlin.gradle.tasks.KotlinCompileTool', false, kotlin.getClass().classLoader) + } catch (Throwable ignore) {} + } + compileTypes.each { type -> + try { + p.tasks.withType(type).each { t -> + try { + targets << t.destinationDirectory.get().asFile.absolutePath + } catch (Exception e1) { + try { targets << t.destinationDir.absolutePath } catch (Exception ignore) {} + } + } + } catch (Exception ignore) {} + } + } // `buildFile` is the configured script, absent on disk for a project configured from the root or // a convention plugin. Still emitted: a direct dependency's mark must name its subproject. def buildFiles = [] diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index 810d3aaa5..37375252d 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -90,7 +90,7 @@ object SocketFactsPlugin extends AutoPlugin { } val moduleDirs: Map[String, (Seq[String], Seq[String])] = - if (withFiles) buildModuleDirs(allRefs, extracted) else Map.empty + if (withFiles) buildModuleDirs(allRefs, extracted, st) else Map.empty // Where sbt recorded the project's own settings as defined (the root build.sbt for a subproject // defined there); positions outside the build are sbt defaults, plugins or our injected base. @@ -197,15 +197,16 @@ object SocketFactsPlugin extends AutoPlugin { // (IntegrationTest, ...) reach the analysis; source-file-level exclusion is coana's job. private def buildModuleDirs( allRefs: Seq[ProjectRef], - extracted: Extracted + extracted: Extracted, + state: State ): Map[String, (Seq[String], Seq[String])] = { allRefs.map { ref => val mid = rootIdOf(extracted, ref) val ver = if (mid.revision == null) "" else mid.revision val configs: Seq[Configuration] = extracted.getOpt(thisProject.in(ref)).map(_.configurations).getOrElse(Seq(Compile, Test)) - val sources = configs - .flatMap(c => extracted.getOpt(sourceDirectories.in(ref).in(c)).getOrElse(Nil)) + val sources = (configs.flatMap(c => extracted.getOpt(sourceDirectories.in(ref).in(c)).getOrElse(Nil)) ++ + baseDirSources(ref, configs, extracted, state)) .map(_.getAbsolutePath).distinct.sorted val targets = configs .flatMap(c => extracted.getOpt(classDirectory.in(ref).in(c))) @@ -214,6 +215,31 @@ object SocketFactsPlugin extends AutoPlugin { }.toMap } + // Sources sbt compiles from the base dir itself (`sourcesInBase`), as files: sbt includes them + // non-recursively, and the base dir as a source root would be walked into nested subprojects. + private def baseDirSources( + ref: ProjectRef, + configs: Seq[Configuration], + extracted: Extracted, + state: State + ): Seq[File] = { + val base = extracted.get(baseDirectory.in(ref)).getCanonicalFile + // A configuration that only inherits the task (Runtime from Compile) would re-run the same walk. + val definingScopes = configs.flatMap { c => + val key = unmanagedSources.in(ref).in(c) + extracted.structure.data.definingScope(key.scope, key.key) + }.distinct + definingScopes.flatMap { scope => + Project.runTask(Def.ScopedKey(scope, unmanagedSources.key), state) match { + case Some((_, sbt.Value(files))) => + files.groupBy(_.getAbsoluteFile.getParentFile).collect { + case (dir, inDir) if dir.getCanonicalFile == base => inDir + }.flatten + case _ => Nil + } + } + } + // A failure naming no module is an artifact that could not be fetched, which only the lenient // report's missing artifacts reveal; with neither, the project is unscannable. private def runUpdateResilient( diff --git a/src/commands/manifest/scripts/test/sbt-compat/project/Base.scala b/src/commands/manifest/scripts/test/sbt-compat/project/Base.scala new file mode 100644 index 000000000..0075bb2e3 --- /dev/null +++ b/src/commands/manifest/scripts/test/sbt-compat/project/Base.scala @@ -0,0 +1 @@ +object Base diff --git a/src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh b/src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh index 97f6fb002..064f35784 100755 --- a/src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh @@ -7,7 +7,8 @@ # - the two expected dependency nodes are present (demo.ext:tool prod, demo.ext:harness test); # - tool appears in a prod root, harness only in non-prod roots -> the assembler's dev flag; # - every component is reachable from a direct dependency of its own root (no orphaned components); -# - both get an on-disk jar `file` record under -Dsocket.withFiles. +# - both get an on-disk jar `file` record under -Dsocket.withFiles; +# - a source in the base dir is a `projectSrc` file, never the base dir itself. # # Both deps are stubs generated into project/localrepo at test time (../make-stub-repo.sh, shared with # the Gradle and Maven fixtures), so neither can age into a CVE alert or a version bump. @@ -48,14 +49,16 @@ echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION)" -Dsbt.ivy.home="$IVY" \ -Dsocket.withFiles=true -Dsocket.recordsFile="$RECORDS" --batch socketFacts ) -python3 - "$RECORDS" <<'PY' -import sys +python3 - "$RECORDS" "$PROJECT" <<'PY' +import os, sys rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] -roots, nodes, files = {}, {}, {} +base = os.path.realpath(sys.argv[2]) +roots, nodes, files, srcs = {}, {}, {}, set() for r in rows: if r[0] == 'root': roots[r[1]] = (r[4] == '1') elif r[0] == 'node': nodes.setdefault(r[2], set()).add(r[1]) elif r[0] == 'file': files.setdefault(r[2], set()).add(r[3]) + elif r[0] == 'projectSrc': srcs.add(os.path.realpath(r[2])) errors = [] tool = 'demo.ext:tool:jar:1.0' @@ -69,6 +72,8 @@ if harness in nodes and any(roots.get(rid) for rid in nodes[harness]): def has_jar(cid): return any(p.endswith('.jar') for p in files.get(cid, ())) for cid in (tool, harness): if not has_jar(cid): errors.append(f"{cid} missing materialized jar under --with-files: {files.get(cid)}") +if os.path.join(base, 'Base.scala') not in srcs: errors.append(f"base-dir source Base.scala not a projectSrc: {sorted(srcs)}") +if base in srcs: errors.append("base dir emitted as a projectSrc root") if errors: print("FAIL:")