Skip to content
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 55 additions & 2 deletions src/commands/manifest/scripts/socket-facts.init.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 = []
Expand Down
34 changes: 30 additions & 4 deletions src/commands/manifest/scripts/socket-facts.plugin.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)))
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
object Base
13 changes: 9 additions & 4 deletions src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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'
Expand All @@ -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:")
Expand Down
Loading