From 547dc356732d7090f9c772e4e388f0c171a90879 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Sun, 1 Nov 2026 08:50:00 +0100 Subject: [PATCH] fix(manifest): attach Maven relocation targets in sbt facts Coursier reports a relocation target with the relocating module as its caller, but leaves that module out of the update report, so the target had no parent and the facts run failed. Read the relocation from the POMs and attach the target to whoever declared the relocating module: the project, a dependsOn project, or a library POM that passes it on. --- .gitignore | 6 + CHANGELOG.md | 1 + .../scripts/socket-facts.plugin.scala | 242 +++++++++++++++--- .../manifest/scripts/test/make-stub-repo.sh | 53 +++- .../manifest/scripts/test/run-compat.sh | 1 + .../scripts/test/sbt-compat/project/build.sbt | 7 +- .../test/sbt-compat/relocation/build.sbt | 21 ++ .../test/sbt-compat/smoke-test-relocation.sh | 79 ++++++ .../scripts/test/sbt-compat/smoke-test.sh | 13 +- 9 files changed, 380 insertions(+), 43 deletions(-) create mode 100644 src/commands/manifest/scripts/test/sbt-compat/relocation/build.sbt create mode 100755 src/commands/manifest/scripts/test/sbt-compat/smoke-test-relocation.sh diff --git a/.gitignore b/.gitignore index d6bb54053c..3dd4c3ebf5 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,12 @@ test/fixtures/commands/fix/e2e-test-py-temp-* /src/commands/manifest/scripts/test/sbt-compat/project/localrepo/ /src/commands/manifest/scripts/test/sbt-compat/project/records.tsv /src/commands/manifest/scripts/test/sbt-compat/project/target/ +/src/commands/manifest/scripts/test/sbt-compat/relocation/*/target/ +/src/commands/manifest/scripts/test/sbt-compat/relocation/localrepo/ +/src/commands/manifest/scripts/test/sbt-compat/relocation/project/ +/src/commands/manifest/scripts/test/sbt-compat/relocation/records.tsv +/src/commands/manifest/scripts/test/sbt-compat/relocation/scala-version.sbt +/src/commands/manifest/scripts/test/sbt-compat/relocation/target/ # Generated when the dynamic SBOM inference tests walk their fixtures # (test/fixtures/commands/manifest/dynamic-sbom-inference). Those fixtures are diff --git a/CHANGELOG.md b/CHANGELOG.md index dd2fd26301..1e80891d51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `--exclude-paths` patterns are now matched relative to the scan root in every build root. They now also exclude modules a build reaches outside its own directory (Maven `../` modules, relocated Gradle projects) and modules matched by wildcards that span a build-root boundary, such as `*/core`. - `--exclude-paths` patterns written with Windows `\` separators now exclude JVM build modules in single-root runs too. - Maven SBOMs keep the dependencies a module reaches through a sibling module's test-jar. +- sbt SBOMs now generate for builds with relocated Maven dependencies, such as `mysql:mysql-connector-java`. ## [1.12.2](https://github.com/SocketDev/socket-cli/releases/tag/v1.12.2) - 2026-10-30 diff --git a/src/commands/manifest/scripts/socket-facts.plugin.scala b/src/commands/manifest/scripts/socket-facts.plugin.scala index 4d9d7a5b16..6a736865d4 100644 --- a/src/commands/manifest/scripts/socket-facts.plugin.scala +++ b/src/commands/manifest/scripts/socket-facts.plugin.scala @@ -186,11 +186,11 @@ object SocketFactsPlugin extends AutoPlugin { // ---- resolution --------------------------------------------------------- - private def rootIdOf(extracted: Extracted, ref: ProjectRef): ModuleID = { - val sv = extracted.get(scalaVersion.in(ref)) - val sbv = extracted.get(scalaBinaryVersion.in(ref)) - CrossVersion.apply(sv, sbv)(extracted.get(projectID.in(ref))) - } + private def crossOf(extracted: Extracted, ref: ProjectRef): ModuleID => ModuleID = + CrossVersion.apply(extracted.get(scalaVersion.in(ref)), extracted.get(scalaBinaryVersion.in(ref))) + + private def rootIdOf(extracted: Extracted, ref: ProjectRef): ModuleID = + crossOf(extracted, ref)(extracted.get(projectID.in(ref))) // GAV -> a build module's real artifact extension (jar for a normal project). coursier reports an // inter-project dependency with no resolved artifact, so it would otherwise be ext-less and not @@ -346,6 +346,47 @@ object SocketFactsPlugin extends AutoPlugin { dbf.newDocumentBuilder().parse(f) } + // Child elements by local name; "*" for all. + private def pomKids(e: org.w3c.dom.Element, name: String): Seq[org.w3c.dom.Element] = { + val ns = e.getChildNodes + (0 until ns.getLength).map(ns.item).collect { + case c: org.w3c.dom.Element if name == "*" || c.getNodeName.split(':').last == name => c + } + } + + private def pomText(e: org.w3c.dom.Element, name: String): Option[String] = + pomKids(e, name).headOption.map(_.getTextContent.trim).filter(_.nonEmpty) + + private def pomGroupId(e: org.w3c.dom.Element): String = + pomText(e, "groupId").orElse(pomKids(e, "parent").headOption.flatMap(pomText(_, "groupId"))).getOrElse("") + + private val PropertyRef = "\\$\\{([^}]+)\\}".r + + // A POM after inheritance: its dependencies (groupId:artifactId -> declared scope, optional), its + // managed scopes, the BOMs it imports, and its properties. + private final case class PomModel( + deps: Map[String, (String, Boolean)], + managed: Map[String, String], + imports: Seq[(String, String, String)], + props: Map[String, String]) + + private object PomModel { + val empty = PomModel(Map.empty, Map.empty, Nil, Map.empty) + } + + // Until nothing changes, as a property's value may refer to another. + private def interpolate(s: String, props: Map[String, String]): String = { + var cur = s + var prev = "" + var rounds = 0 + while (cur != prev && rounds < 16) { + prev = cur + cur = PropertyRef.replaceAllIn(cur, m => java.util.regex.Matcher.quoteReplacement(props.getOrElse(m.group(1), m.matched))) + rounds += 1 + } + cur + } + // Ivy's ResolveOptions.getDefaultResolveId: `-` of the resolved descriptor. private def ivyResolveId(descriptor: File): Option[String] = try { @@ -464,10 +505,21 @@ object SocketFactsPlugin extends AutoPlugin { !m.evicted && !moduleExts.contains(gavKey(m.module)) && !unresolved.contains(gavKey(m.module)) def confsOf(r: ProjectRef): Seq[Configuration] = extracted.getOpt(ivyConfigurations.in(r)).getOrElse(Nil) + lazy val refByProject: Map[String, ProjectRef] = extracted.structure.allProjectRefs.map(r => r.project -> r).toMap + // Whether build project `r` declares module `c` in one of `confs`. A declaration without a + // configuration goes to Compile; the report names the module cross-versioned. + def declares(r: ProjectRef, c: ModuleID, confs: Set[String]): Boolean = { + val cross = crossOf(extracted, r) + extracted.getOpt(libraryDependencies.in(r)).getOrElse(Nil).exists { d => + val masters = d.configurations.getOrElse("compile").split(';').flatMap(_.split("->").head.split(',')).map(_.trim) + d.organization == c.organization && cross(d).name == c.name && masters.exists(m => m == "*" || confs(m)) + } + } val buildDeps = extracted.get(buildDependencies) - // Classpaths.interSort, recording the (project -> dependency project) edges it follows. - def interProjectEdges(conf: Configuration): mutable.LinkedHashSet[(ProjectRef, ProjectRef)] = { + // Classpaths.interSort, recording the (project -> dependency project) edges it follows and the + // configurations of each project it reaches. + def interProjectEdges(conf: Configuration): (mutable.LinkedHashSet[(ProjectRef, ProjectRef)], Map[String, Set[String]]) = { val masterConfs = confsOf(ref).map(_.name) val visited = mutable.HashSet.empty[(ProjectRef, String)] val edges = mutable.LinkedHashSet.empty[(ProjectRef, ProjectRef)] @@ -488,7 +540,7 @@ object SocketFactsPlugin extends AutoPlugin { } } visit(ref, conf) - edges + (edges, visited.toSeq.groupBy(_._1.project).map { case (p, vs) => p -> vs.map(_._2).toSet }) } def buildModuleCoord(r: ProjectRef): Coord = { @@ -510,6 +562,7 @@ object SocketFactsPlugin extends AutoPlugin { val nodes = mutable.LinkedHashMap.empty[String, Node] // org:name -> component ids (caller edges are module-level). val midToIds = mutable.HashMap.empty[String, mutable.LinkedHashSet[String]] + var reachedConfs = Map.empty[String, Set[String]] confsOf(ref).find(_.name == cfg).foreach { conf => // Keyed by project id, as projects need not have distinct coordinates. @@ -522,7 +575,9 @@ object SocketFactsPlugin extends AutoPlugin { }) r.project } - interProjectEdges(conf).foreach { case (p, d) => + val (edges, reached) = interProjectEdges(conf) + reachedConfs = reached + edges.foreach { case (p, d) => val child = buildModuleNode(d) if (p == ref) nodes(child).direct = true else nodes(buildModuleNode(p)).children += child @@ -549,19 +604,29 @@ object SocketFactsPlugin extends AutoPlugin { } // Caller edges within this config: a root caller marks the child direct, any other becomes its parent. - cr.modules.foreach { m => - if (emittable(m)) { - midToIds.get(moduleKey(m.module)).foreach { childIds => - // lm-coursier gives a declared caller its declared revision, not the resolved one, so - // callers match by org:name (a configuration resolves one version per module). - m.callers.foreach { c => - val callerKey = moduleKey(c.caller) - if (callerKey == rootKey) childIds.foreach(cid => nodes(cid).direct = true) - else - midToIds.get(callerKey).foreach { parentIds => - // Drop self-edges (test → main resolving to the same coordinate), matching gradle. - parentIds.foreach(pid => childIds.foreach(cid => if (pid != cid) nodes(pid).children += cid)) - } + // Coursier resolves a Maven relocation's target in place of the relocating module, but reports + // neither the relocating module nor who declared it, so that caller is missing from the report. + // Its declarers are read off the build's own declarations and the POMs instead. + val candidates = cr.modules.filter(emittable) + // Drops self-edges (test → main resolving to the same coordinate), matching gradle. + def link(parentIds: Iterable[String], childIds: Iterable[String]): Unit = + parentIds.foreach(pid => childIds.foreach(cid => if (pid != cid) nodes(pid).children += cid)) + def declaredBy(p: String, c: ModuleID): Boolean = + refByProject.get(p).exists(declares(_, c, reachedConfs.getOrElse(p, Set.empty))) + candidates.foreach { m => + midToIds.get(moduleKey(m.module)).foreach { childIds => + // lm-coursier gives a declared caller its declared revision, not the resolved one, so + // callers match by org:name (a configuration resolves one version per module). + m.callers.foreach { c => + val callerKey = moduleKey(c.caller) + if (callerKey == rootKey) childIds.foreach(cid => nodes(cid).direct = true) + else midToIds.get(callerKey) match { + case Some(parentIds) => link(parentIds, childIds) + case None if !resolver.ivy && resolver.poms.relocates(c.caller, m) => + if (declaredBy(ref.project, c.caller)) childIds.foreach(cid => nodes(cid).direct = true) + link(nodes.collect { case (nid, n) if n.project.nonEmpty && declaredBy(n.project, c.caller) => nid }, childIds) + resolver.poms.declaringModules(c.caller, candidates).foreach(p => midToIds.get(moduleKey(p)).foreach(link(_, childIds))) + case None => } } } @@ -592,7 +657,7 @@ object SocketFactsPlugin extends AutoPlugin { // Artifacts the resolver expected and did not fetch. Under Ivy they come from its resolution report // instead (see ivyUnfetchedOf). - private final class Resolver(val ivy: Boolean, poms: PomPackaging) { + private final class Resolver(val ivy: Boolean, val poms: PomPackaging) { def missingOf(m: ModuleReport): List[Artifact] = if (ivy) Nil else Option(m.missingArtifacts).toList.flatten.filterNot(poms.isUnexpectedDefault) } @@ -601,15 +666,126 @@ object SocketFactsPlugin extends AutoPlugin { // the POM declares a jar packaging. Only the module's own POM tells whether its artifact is a jar // (packaging defaults to jar, so a parent's never applies); an unreadable POM counts as one. private final class PomPackaging(csrCacheDir: Option[File]) { - private val memo = mutable.HashMap.empty[String, Boolean] + private val pomMemo = mutable.HashMap.empty[String, Option[org.w3c.dom.Element]] + + private def pom(url: String): Option[org.w3c.dom.Element] = + pomMemo.getOrElseUpdate(url, coursierLocalFiles(url, csrCacheDir).find(_.isFile).flatMap { f => + try Some(parseXml(f).getDocumentElement) + catch { case _: Exception => None } + }) + + // Maven repository layout. + private def moduleDir(base: String, org: String, name: String): String = + base + org.replace('.', '/') + "/" + name + "/" + + private def pomUrl(base: String, org: String, name: String, rev: String): String = + moduleDir(base, org, name) + rev + "/" + name + "-" + rev + ".pom" + + // The repository root a module's artifacts were fetched from. + private def repoBase(m: ModuleReport): Option[String] = { + val mid = m.module + val marker = moduleDir("/", mid.organization, mid.name) + orEmpty(mid.revision) + "/" + val urls = Option(m.artifacts).toList.flatten.flatMap(_._1.url) ++ + Option(m.missingArtifacts).toList.flatten.flatMap(_.url) + urls.map(_.toString).collectFirst { case u if u.contains(marker) => u.substring(0, u.indexOf(marker) + 1) } + } + + private val modelMemo = mutable.HashMap.empty[String, PomModel] + + // Maven model inheritance and interpolation of a POM, as far as its dependencies' scopes need. + private def model(base: String, org: String, name: String, rev: String, depth: Int): PomModel = { + val url = pomUrl(base, org, name, rev) + modelMemo.getOrElseUpdate(url, pom(url) match { + case None => PomModel.empty + case Some(e) => + val parent = pomKids(e, "parent").headOption + val pm = parent match { + case Some(p) if depth < 16 => + model(base, pomText(p, "groupId").getOrElse(""), pomText(p, "artifactId").getOrElse(""), + pomText(p, "version").getOrElse(""), depth + 1) + case _ => PomModel.empty + } + val groupId = pomGroupId(e) + val artifactId = pomText(e, "artifactId").getOrElse("") + val version = pomText(e, "version").orElse(parent.flatMap(pomText(_, "version"))).getOrElse("") + val own = pomKids(e, "properties").flatMap(ps => pomKids(ps, "*")).map(p => p.getNodeName.split(':').last -> p.getTextContent.trim) + val props = pm.props ++ own ++ Seq( + "project.groupId" -> groupId, "pom.groupId" -> groupId, "groupId" -> groupId, + "project.artifactId" -> artifactId, "pom.artifactId" -> artifactId, "artifactId" -> artifactId, + "project.version" -> version, "pom.version" -> version, "version" -> version + ) ++ parent.toSeq.flatMap { p => + Seq("project.parent.groupId" -> pomText(p, "groupId").getOrElse(""), + "project.parent.version" -> pomText(p, "version").getOrElse("")) + } + def field(d: _root_.org.w3c.dom.Element, name: String) = interpolate(pomText(d, name).getOrElse(""), props) + def ga(d: _root_.org.w3c.dom.Element) = field(d, "groupId") + ":" + field(d, "artifactId") + val deps = pomKids(e, "dependencies").flatMap(ds => pomKids(ds, "dependency")) + val managed = pomKids(e, "dependencyManagement").flatMap(pomKids(_, "dependencies")).flatMap(pomKids(_, "dependency")) + val (imports, scoped) = managed.partition(d => field(d, "scope") == "import" && field(d, "type") == "pom") + PomModel( + pm.deps ++ deps.map(d => ga(d) -> ((field(d, "scope"), field(d, "optional") == "true"))), + pm.managed ++ scoped.map(d => ga(d) -> field(d, "scope")), + imports.map(d => (field(d, "groupId"), field(d, "artifactId"), field(d, "version"))) ++ pm.imports, + props) + }) + } + + private val managedMemo = mutable.HashMap.empty[String, Map[String, String]] + + // Managed scopes after BOM imports: the POM's own and inherited entries win, then the first import. + private def managedScopes(base: String, org: String, name: String, rev: String, depth: Int): Map[String, String] = + managedMemo.getOrElseUpdate(pomUrl(base, org, name, rev), { + val m = model(base, org, name, rev, 0) + m.imports.reverse.foldLeft(Map.empty[String, String]) { case (acc, (g, a, v)) => + if (depth < 16) acc ++ managedScopes(base, g, a, v, depth + 1) else acc + } ++ m.managed + }) + + // org:name of the modules each relocating module's cached POMs relocate to. + private val relocationsMemo = mutable.HashMap.empty[(String, String), Set[String]] + + // Whether `relocating` is a POM relocated to `target`'s module. The report gives the relocating + // module its declared revision, which may be a range, so every cached version is tried; the + // target may have resolved to another version than the relocation names. + def relocates(relocating: ModuleID, target: ModuleReport): Boolean = + repoBase(target).exists { base => + relocationsMemo.getOrElseUpdate((base, gavKey(relocating)), { + val dir = moduleDir(base, relocating.organization, relocating.name) + val versions = orEmpty(relocating.revision) +: coursierLocalFiles(dir, csrCacheDir) + .flatMap(d => Option(d.listFiles).toSeq.flatten).filter(_.isDirectory).map(_.getName) + versions.distinct.flatMap { v => + pom(pomUrl(base, relocating.organization, relocating.name, v)).toSeq.flatMap { e => + pomKids(e, "distributionManagement").flatMap(pomKids(_, "relocation")).headOption.map { r => + pomText(r, "groupId").getOrElse(pomGroupId(e)) + ":" + + pomText(r, "artifactId").orElse(pomText(e, "artifactId")).getOrElse("") + } + } + }.toSet + })(moduleKey(target.module)) + } + + // The modules among `candidates` whose POM passes `declared` on to their dependents: compile or + // runtime scope, declared or managed, and not optional. + def declaringModules(declared: ModuleID, candidates: Seq[ModuleReport]): Seq[ModuleID] = { + val key = moduleKey(declared) + candidates.filter { p => + repoBase(p).exists { base => + val m = model(base, p.module.organization, p.module.name, orEmpty(p.module.revision), 0) + m.deps.get(key).exists { case (scope, optional) => + val effective = + if (scope.nonEmpty) scope + else managedScopes(base, p.module.organization, p.module.name, orEmpty(p.module.revision), 0).getOrElse(key, "") + Set("", "compile", "runtime")(effective) && !optional + } + } + }.map(_.module) + } def isUnexpectedDefault(a: Artifact): Boolean = { val ext = extOf(a) a.classifier.isEmpty && ext == "jar" && a.url.map(_.toString).exists { url => - url.endsWith("." + ext) && memo.getOrElseUpdate(url, { - val pomUrl = url.dropRight(ext.length) + "pom" - coursierLocalFiles(pomUrl, csrCacheDir).find(_.isFile).flatMap(pomPackaging).exists(p => !JarPackagings(p)) - }) + url.endsWith("." + ext) && + pom(url.dropRight(ext.length) + "pom").exists(e => !JarPackagings(pomText(e, "packaging").getOrElse("jar"))) } } } @@ -643,16 +819,6 @@ object SocketFactsPlugin extends AutoPlugin { if (ch > 128 || " %$&+,:;=?@<>#".indexOf(ch) >= 0) "%" + "%02X".format(ch.toInt) else ch.toString } - // Maven's default packaging is jar. - private def pomPackaging(pom: File): Option[String] = - try { - val kids = parseXml(pom).getDocumentElement.getChildNodes - val declared = (0 until kids.getLength).map(kids.item).collectFirst { - case e: org.w3c.dom.Element if e.getNodeName.split(':').last == "packaging" => e.getTextContent.trim - } - Some(declared.filter(_.nonEmpty).getOrElse("jar")) - } catch { case _: Exception => None } - // ---- config selection --------------------------------------------------- // With no includes the default is ALL configurations (captures build/tooling deps). diff --git a/src/commands/manifest/scripts/test/make-stub-repo.sh b/src/commands/manifest/scripts/test/make-stub-repo.sh index fed643bd0e..f6ef753024 100755 --- a/src/commands/manifest/scripts/test/make-stub-repo.sh +++ b/src/commands/manifest/scripts/test/make-stub-repo.sh @@ -10,7 +10,9 @@ # Usage: make-stub-repo.sh ... # spec = group:artifact:version[+dep-group:dep-artifact:dep-version[,...]] # Deps listed after `+` are written as compile-scope dependencies of that stub, so a consumer that -# depends on the stub also pulls them transitively. +# depends on the stub also pulls them transitively; `dep@scope` writes that scope instead. +# spec = group:artifact:version>new-group:new-artifact:new-version writes a POM that relocates the +# stub to the new coordinate, and no jar. set -euo pipefail REPO="${1:?usage: make-stub-repo.sh ...}" @@ -28,6 +30,32 @@ EMPTY="$(mktemp -d)" trap 'rm -rf "$EMPTY"' EXIT for spec in "$@"; do + if [[ "$spec" == *">"* ]]; then + IFS=: read -r group art ver <<<"${spec%%>*}" + IFS=: read -r ngroup nart nver <<<"${spec#*>}" + if [ -z "${group:-}" ] || [ -z "${art:-}" ] || [ -z "${ver:-}" ] || [ -z "${ngroup:-}" ] || [ -z "${nart:-}" ] || [ -z "${nver:-}" ]; then + echo "make-stub-repo.sh: malformed relocation '$spec' (want group:artifact:version>group:artifact:version)" >&2 + exit 1 + fi + dir="$REPO/${group//.//}/$art/$ver" + mkdir -p "$dir" + cat >"$dir/$art-$ver.pom" < + 4.0.0 + $group + $art + $ver + + + $ngroup + $nart + $nver + + + +POM + continue + fi coord="${spec%%+*}" deps="" [ "$coord" = "$spec" ] || deps="${spec#*+}" @@ -51,7 +79,9 @@ for spec in "$@"; do echo ' ' IFS=, read -r -a dep_list <<<"$deps" for dep in "${dep_list[@]}"; do - IFS=: read -r dgroup dart dver <<<"$dep" + dscope="" + [ "${dep%@*}" = "$dep" ] || dscope="${dep#*@}" + IFS=: read -r dgroup dart dver <<<"${dep%@*}" if [ -z "${dgroup:-}" ] || [ -z "${dart:-}" ] || [ -z "${dver:-}" ]; then echo "make-stub-repo.sh: malformed dependency '$dep' in spec '$spec'" >&2 exit 1 @@ -60,6 +90,7 @@ for spec in "$@"; do echo " $dgroup" echo " $dart" echo " $dver" + [ -z "$dscope" ] || echo " $dscope" echo ' ' done echo ' ' @@ -71,4 +102,22 @@ for spec in "$@"; do ( cd "$EMPTY" && jar cf "$dir/$art-$ver.jar" . ) done +# A version list per module, so a version range can resolve against the repo. +find "$REPO" -name '*.pom' | while read -r pom; do + dir="$(dirname "$(dirname "$pom")")" + art="$(basename "$dir")" + group="$(dirname "${dir#"$REPO"/}")" + { + echo '' + echo " ${group//\//.}" + echo " $art" + echo ' ' + echo ' ' + for v in "$dir"/*/; do echo " $(basename "$v")"; done + echo ' ' + echo ' ' + echo '' + } >"$dir/maven-metadata.xml" +done + echo "built stub repo at $REPO" diff --git a/src/commands/manifest/scripts/test/run-compat.sh b/src/commands/manifest/scripts/test/run-compat.sh index 6b2b0ab2a0..eaa8d75125 100755 --- a/src/commands/manifest/scripts/test/run-compat.sh +++ b/src/commands/manifest/scripts/test/run-compat.sh @@ -115,6 +115,7 @@ run_sbt() { bash "$HERE/sbt-compat/smoke-test-exclude-base.sh" "$ver" "$scala" fi bash "$HERE/sbt-compat/smoke-test-failure.sh" "$ver" "$scala" ${resolver:+"$resolver"} + bash "$HERE/sbt-compat/smoke-test-relocation.sh" "$ver" "$scala" ${resolver:+"$resolver"} done } diff --git a/src/commands/manifest/scripts/test/sbt-compat/project/build.sbt b/src/commands/manifest/scripts/test/sbt-compat/project/build.sbt index ec2e92f71e..f72bace27e 100644 --- a/src/commands/manifest/scripts/test/sbt-compat/project/build.sbt +++ b/src/commands/manifest/scripts/test/sbt-compat/project/build.sbt @@ -1,5 +1,6 @@ -// Minimal cross-sbt smoke project for ../../socket-facts.plugin.scala. Resolves one prod dep and one -// test dep so the smoke test can assert the prod/dev split and --with-files materialization. Both are +// Minimal cross-sbt smoke project for ../../socket-facts.plugin.scala. Resolves prod deps (two through +// a Maven relocation) and one test dep so the smoke test can assert the prod/dev split, relocations +// and --with-files materialization. All are // stub artifacts generated into `localrepo` at test time (../../make-stub-repo.sh): plain Java // artifacts needing no Scala cross-version, never compiled against, so they can never age into a CVE // alert or a version bump. Uses the `in ThisBuild` setting form (not the `/` slash form) so it parses @@ -20,5 +21,7 @@ lazy val root = (project in file(".")) "Maven Central" at "https://repo1.maven.org/maven2/" ) ++ resolvers.value, libraryDependencies += "demo.ext" % "tool" % "1.0", + // Relocated to demo.ext:moved; tool depends on demo.old:legacy, relocated to demo.ext:legacy. + libraryDependencies += "demo.old" % "moved" % "1.0", libraryDependencies += "demo.ext" % "harness" % "1.0" % Test ) diff --git a/src/commands/manifest/scripts/test/sbt-compat/relocation/build.sbt b/src/commands/manifest/scripts/test/sbt-compat/relocation/build.sbt new file mode 100644 index 0000000000..ebdb848d52 --- /dev/null +++ b/src/commands/manifest/scripts/test/sbt-compat/relocation/build.sbt @@ -0,0 +1,21 @@ +// Relocation fixture for ../../socket-facts.plugin.scala. core declares demo.old:moved, which +// relocates to demo.ext:moved, by a version range; app gets demo.ext:moved only through core, and +// overrides its version to one the relocation does not name. The +// demo.ext:wrapper POM declares demo.old:moved with the test scope, which it does not pass on, so it +// must not become demo.ext:moved's parent. Stub artifacts come from `localrepo` +// (../../make-stub-repo.sh). Uses the `in ThisBuild` setting form so it parses on sbt 0.13 and 1.x. +organization in ThisBuild := "demo" +version in ThisBuild := "0.1.0" + +lazy val stubs = Seq( + resolvers := Seq( + "Socket Stubs" at s"file://${(baseDirectory in ThisBuild).value.getAbsolutePath}/localrepo", + "Maven Central" at "https://repo1.maven.org/maven2/" + ) ++ resolvers.value +) + +lazy val core = (project in file("core")).settings(stubs, libraryDependencies += "demo.old" % "moved" % "[1.0,2.0)") + +lazy val app = (project in file("app")) + .dependsOn(core) + .settings(stubs, libraryDependencies += "demo.ext" % "wrapper" % "1.0", dependencyOverrides += "demo.ext" % "moved" % "1.1") diff --git a/src/commands/manifest/scripts/test/sbt-compat/smoke-test-relocation.sh b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-relocation.sh new file mode 100755 index 0000000000..ae4b43a7c2 --- /dev/null +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test-relocation.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Run socket-facts.plugin.scala on the relocation fixture and assert a Maven relocation's target keeps +# the parent that declared the relocating module: the project itself (direct) or a build project it +# depends on, never a library that declares it with a scope it does not pass on. Coursier reports the +# target with a caller it leaves out of the report, and gives that caller its declared revision, here +# a version range; an override can move the target off the version the relocation names. +# +# Usage: smoke-test-relocation.sh [coursier|ivy] (ivy: sbt 1.x only) +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +SBT_VERSION="${1:?usage: smoke-test-relocation.sh }" +SCALA_VERSION="${2:?usage: smoke-test-relocation.sh }" +RESOLVER="${3:-coursier}" +PLUGIN="$HERE/../../socket-facts.plugin.scala" +PROJECT="$HERE/relocation" +RECORDS="$PROJECT/records.tsv" +# shellcheck source=SCRIPTDIR/../compat-cache.sh +. "$HERE/../compat-cache.sh" +IVY="$SOCKET_COMPAT_CACHE/ivy2" +export COURSIER_CACHE="$SOCKET_COMPAT_CACHE/coursier" + +bash "$HERE/../make-stub-repo.sh" "$PROJECT/localrepo" \ + 'demo.old:moved:1.0>demo.ext:moved:1.0' \ + 'demo.ext:moved:1.0' \ + 'demo.ext:moved:1.1' \ + 'demo.ext:wrapper:1.0+demo.old:moved:1.0@test' +rm -rf "$IVY/cache/demo.ext" "$IVY/cache/demo.old" + +GB="$(mktemp -d)/global-base" +mkdir -p "$GB/plugins" +cp "$PLUGIN" "$GB/plugins/SocketFactsPlugin.scala" +mkdir -p "$PROJECT/project" +echo "sbt.version=$SBT_VERSION" > "$PROJECT/project/build.properties" +echo "scalaVersion in ThisBuild := \"$SCALA_VERSION\"" > "$PROJECT/scala-version.sbt" +if [ "$RESOLVER" = ivy ]; then + echo 'useCoursier in ThisBuild := false' >> "$PROJECT/scala-version.sbt" +fi +rm -rf "$RECORDS" "$PROJECT/target" "$PROJECT/project/target" "$PROJECT"/*/target + +echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION) (relocation, $RESOLVER)" +( cd "$PROJECT" && sbt -Dsbt.global.base="$GB" -Dsbt.server.autostart=false \ + -Dsbt.ivy.home="$IVY" -Dsocket.recordsFile="$RECORDS" --batch socketFacts ) + +python3 - "$RECORDS" <<'PY' +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +root_of = {r[1]: (r[2], r[3]) for r in rows if r[0] == 'root'} +# Coursier leaves the relocating module out, so the target hangs under its declarer; Ivy keeps it as +# an artifact-less node between the two, which counts as the declarer's edge. +moved = {'core': 'demo.ext:moved:jar:1.0', 'app': 'demo.ext:moved:jar:1.1'} +relocating, wrapper = 'demo.old:moved:1.0', 'demo.ext:wrapper:jar:1.0' +errors = [] +for rid, (project, config) in root_of.items(): + nodes = {r[2]: r for r in rows if r[0] == 'node' and r[1] == rid} + target = moved.get(project) + if target not in nodes: + continue + parents_of = lambda c: {r[2] for r in rows if r[0] == 'edge' and r[1] == rid and r[3] == c} + parents, direct = parents_of(target), nodes[target][8] == '1' + if relocating in parents: + parents = (parents - {relocating}) | parents_of(relocating) + direct = direct or nodes[relocating][8] == '1' + if project == 'core' and not direct: + errors.append(f"core/{config}: {target} not direct") + if project == 'app' and parents != {'core'}: + errors.append(f"app/{config}: {target} parents {sorted(parents)} != ['core']") + if wrapper in parents: + errors.append(f"{project}/{config}: {wrapper} made a parent of {target}") +for project, target in moved.items(): + if not any(r[0] == 'node' and r[2] == target and root_of[r[1]][0] == project for r in rows): + errors.append(f"{target} missing from {project}") +if errors: + print("FAIL:") + for e in errors: print(" -", e) + sys.exit(1) +print(f"PASS (relocation): demo.ext:moved direct in core, under core in app (overridden), never under {wrapper}") +PY + +python3 "$HERE/../assert-reachability.py" "$RECORDS" 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 064f35784c..801d831c9f 100755 --- a/src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh +++ b/src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh @@ -8,6 +8,8 @@ # - 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; +# - a Maven relocation's target, declared directly (demo.old:moved) or through a dependency +# (demo.old:legacy, from tool), stays in the graph although coursier leaves its caller out of the report; # - 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 @@ -27,7 +29,13 @@ RECORDS="$PROJECT/records.tsv" IVY="$SOCKET_COMPAT_CACHE/ivy2" export COURSIER_CACHE="$SOCKET_COMPAT_CACHE/coursier" -bash "$HERE/../make-stub-repo.sh" "$PROJECT/localrepo" 'demo.ext:tool:1.0' 'demo.ext:harness:1.0' +bash "$HERE/../make-stub-repo.sh" "$PROJECT/localrepo" \ + 'demo.ext:tool:1.0+demo.old:legacy:1.0' \ + 'demo.old:legacy:1.0>demo.ext:legacy:1.0' \ + 'demo.ext:legacy:1.0' \ + 'demo.old:moved:1.0>demo.ext:moved:1.0' \ + 'demo.ext:moved:1.0' \ + 'demo.ext:harness:1.0' # scala-library and friends stay cached between runs; the stubs never do, so every run has to # resolve them from the repo just generated. Only Ivy caches them — coursier reads a `file:` # repository in place. @@ -72,6 +80,9 @@ 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)}") +for cid in ('demo.ext:legacy:jar:1.0', 'demo.ext:moved:jar:1.0'): + if cid not in nodes: errors.append(f"relocation target missing: {cid}") + elif not any(roots.get(rid) for rid in nodes[cid]): errors.append(f"relocation target {cid} not in a prod root") 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")