Skip to content

Commit 319db5e

Browse files
authored
fix(manifest): attach Maven relocation targets in sbt facts (#1685)
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.
1 parent 80e50d9 commit 319db5e

9 files changed

Lines changed: 380 additions & 43 deletions

File tree

‎.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ test/fixtures/commands/fix/e2e-test-py-temp-*
4747
/src/commands/manifest/scripts/test/sbt-compat/project/localrepo/
4848
/src/commands/manifest/scripts/test/sbt-compat/project/records.tsv
4949
/src/commands/manifest/scripts/test/sbt-compat/project/target/
50+
/src/commands/manifest/scripts/test/sbt-compat/relocation/*/target/
51+
/src/commands/manifest/scripts/test/sbt-compat/relocation/localrepo/
52+
/src/commands/manifest/scripts/test/sbt-compat/relocation/project/
53+
/src/commands/manifest/scripts/test/sbt-compat/relocation/records.tsv
54+
/src/commands/manifest/scripts/test/sbt-compat/relocation/scala-version.sbt
55+
/src/commands/manifest/scripts/test/sbt-compat/relocation/target/
5056

5157
# Generated when the dynamic SBOM inference tests walk their fixtures
5258
# (test/fixtures/commands/manifest/dynamic-sbom-inference). Those fixtures are

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1313
- `--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`.
1414
- `--exclude-paths` patterns written with Windows `\` separators now exclude JVM build modules in single-root runs too.
1515
- Maven SBOMs keep the dependencies a module reaches through a sibling module's test-jar.
16+
- sbt SBOMs now generate for builds with relocated Maven dependencies, such as `mysql:mysql-connector-java`.
1617

1718
## [1.12.2](https://github.com/SocketDev/socket-cli/releases/tag/v1.12.2) - 2026-10-30
1819

‎src/commands/manifest/scripts/socket-facts.plugin.scala‎

Lines changed: 204 additions & 38 deletions
Large diffs are not rendered by default.

‎src/commands/manifest/scripts/test/make-stub-repo.sh‎

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
# Usage: make-stub-repo.sh <repo-dir> <spec>...
1111
# spec = group:artifact:version[+dep-group:dep-artifact:dep-version[,...]]
1212
# Deps listed after `+` are written as compile-scope dependencies of that stub, so a consumer that
13-
# depends on the stub also pulls them transitively.
13+
# depends on the stub also pulls them transitively; `dep@scope` writes that scope instead.
14+
# spec = group:artifact:version>new-group:new-artifact:new-version writes a POM that relocates the
15+
# stub to the new coordinate, and no jar.
1416
set -euo pipefail
1517

1618
REPO="${1:?usage: make-stub-repo.sh <repo-dir> <group:artifact:version[+dep,...]>...}"
@@ -28,6 +30,32 @@ EMPTY="$(mktemp -d)"
2830
trap 'rm -rf "$EMPTY"' EXIT
2931

3032
for spec in "$@"; do
33+
if [[ "$spec" == *">"* ]]; then
34+
IFS=: read -r group art ver <<<"${spec%%>*}"
35+
IFS=: read -r ngroup nart nver <<<"${spec#*>}"
36+
if [ -z "${group:-}" ] || [ -z "${art:-}" ] || [ -z "${ver:-}" ] || [ -z "${ngroup:-}" ] || [ -z "${nart:-}" ] || [ -z "${nver:-}" ]; then
37+
echo "make-stub-repo.sh: malformed relocation '$spec' (want group:artifact:version>group:artifact:version)" >&2
38+
exit 1
39+
fi
40+
dir="$REPO/${group//.//}/$art/$ver"
41+
mkdir -p "$dir"
42+
cat >"$dir/$art-$ver.pom" <<POM
43+
<project xmlns="http://maven.apache.org/POM/4.0.0">
44+
<modelVersion>4.0.0</modelVersion>
45+
<groupId>$group</groupId>
46+
<artifactId>$art</artifactId>
47+
<version>$ver</version>
48+
<distributionManagement>
49+
<relocation>
50+
<groupId>$ngroup</groupId>
51+
<artifactId>$nart</artifactId>
52+
<version>$nver</version>
53+
</relocation>
54+
</distributionManagement>
55+
</project>
56+
POM
57+
continue
58+
fi
3159
coord="${spec%%+*}"
3260
deps=""
3361
[ "$coord" = "$spec" ] || deps="${spec#*+}"
@@ -51,7 +79,9 @@ for spec in "$@"; do
5179
echo ' <dependencies>'
5280
IFS=, read -r -a dep_list <<<"$deps"
5381
for dep in "${dep_list[@]}"; do
54-
IFS=: read -r dgroup dart dver <<<"$dep"
82+
dscope=""
83+
[ "${dep%@*}" = "$dep" ] || dscope="${dep#*@}"
84+
IFS=: read -r dgroup dart dver <<<"${dep%@*}"
5585
if [ -z "${dgroup:-}" ] || [ -z "${dart:-}" ] || [ -z "${dver:-}" ]; then
5686
echo "make-stub-repo.sh: malformed dependency '$dep' in spec '$spec'" >&2
5787
exit 1
@@ -60,6 +90,7 @@ for spec in "$@"; do
6090
echo " <groupId>$dgroup</groupId>"
6191
echo " <artifactId>$dart</artifactId>"
6292
echo " <version>$dver</version>"
93+
[ -z "$dscope" ] || echo " <scope>$dscope</scope>"
6394
echo ' </dependency>'
6495
done
6596
echo ' </dependencies>'
@@ -71,4 +102,22 @@ for spec in "$@"; do
71102
( cd "$EMPTY" && jar cf "$dir/$art-$ver.jar" . )
72103
done
73104

105+
# A version list per module, so a version range can resolve against the repo.
106+
find "$REPO" -name '*.pom' | while read -r pom; do
107+
dir="$(dirname "$(dirname "$pom")")"
108+
art="$(basename "$dir")"
109+
group="$(dirname "${dir#"$REPO"/}")"
110+
{
111+
echo '<metadata>'
112+
echo " <groupId>${group//\//.}</groupId>"
113+
echo " <artifactId>$art</artifactId>"
114+
echo ' <versioning>'
115+
echo ' <versions>'
116+
for v in "$dir"/*/; do echo " <version>$(basename "$v")</version>"; done
117+
echo ' </versions>'
118+
echo ' </versioning>'
119+
echo '</metadata>'
120+
} >"$dir/maven-metadata.xml"
121+
done
122+
74123
echo "built stub repo at $REPO"

‎src/commands/manifest/scripts/test/run-compat.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ run_sbt() {
115115
bash "$HERE/sbt-compat/smoke-test-exclude-base.sh" "$ver" "$scala"
116116
fi
117117
bash "$HERE/sbt-compat/smoke-test-failure.sh" "$ver" "$scala" ${resolver:+"$resolver"}
118+
bash "$HERE/sbt-compat/smoke-test-relocation.sh" "$ver" "$scala" ${resolver:+"$resolver"}
118119
done
119120
}
120121

‎src/commands/manifest/scripts/test/sbt-compat/project/build.sbt‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
// Minimal cross-sbt smoke project for ../../socket-facts.plugin.scala. Resolves one prod dep and one
2-
// test dep so the smoke test can assert the prod/dev split and --with-files materialization. Both are
1+
// Minimal cross-sbt smoke project for ../../socket-facts.plugin.scala. Resolves prod deps (two through
2+
// a Maven relocation) and one test dep so the smoke test can assert the prod/dev split, relocations
3+
// and --with-files materialization. All are
34
// stub artifacts generated into `localrepo` at test time (../../make-stub-repo.sh): plain Java
45
// artifacts needing no Scala cross-version, never compiled against, so they can never age into a CVE
56
// 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("."))
2021
"Maven Central" at "https://repo1.maven.org/maven2/"
2122
) ++ resolvers.value,
2223
libraryDependencies += "demo.ext" % "tool" % "1.0",
24+
// Relocated to demo.ext:moved; tool depends on demo.old:legacy, relocated to demo.ext:legacy.
25+
libraryDependencies += "demo.old" % "moved" % "1.0",
2326
libraryDependencies += "demo.ext" % "harness" % "1.0" % Test
2427
)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Relocation fixture for ../../socket-facts.plugin.scala. core declares demo.old:moved, which
2+
// relocates to demo.ext:moved, by a version range; app gets demo.ext:moved only through core, and
3+
// overrides its version to one the relocation does not name. The
4+
// demo.ext:wrapper POM declares demo.old:moved with the test scope, which it does not pass on, so it
5+
// must not become demo.ext:moved's parent. Stub artifacts come from `localrepo`
6+
// (../../make-stub-repo.sh). Uses the `in ThisBuild` setting form so it parses on sbt 0.13 and 1.x.
7+
organization in ThisBuild := "demo"
8+
version in ThisBuild := "0.1.0"
9+
10+
lazy val stubs = Seq(
11+
resolvers := Seq(
12+
"Socket Stubs" at s"file://${(baseDirectory in ThisBuild).value.getAbsolutePath}/localrepo",
13+
"Maven Central" at "https://repo1.maven.org/maven2/"
14+
) ++ resolvers.value
15+
)
16+
17+
lazy val core = (project in file("core")).settings(stubs, libraryDependencies += "demo.old" % "moved" % "[1.0,2.0)")
18+
19+
lazy val app = (project in file("app"))
20+
.dependsOn(core)
21+
.settings(stubs, libraryDependencies += "demo.ext" % "wrapper" % "1.0", dependencyOverrides += "demo.ext" % "moved" % "1.1")
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/env bash
2+
# Run socket-facts.plugin.scala on the relocation fixture and assert a Maven relocation's target keeps
3+
# the parent that declared the relocating module: the project itself (direct) or a build project it
4+
# depends on, never a library that declares it with a scope it does not pass on. Coursier reports the
5+
# target with a caller it leaves out of the report, and gives that caller its declared revision, here
6+
# a version range; an override can move the target off the version the relocation names.
7+
#
8+
# Usage: smoke-test-relocation.sh <sbt-version> <scala-version> [coursier|ivy] (ivy: sbt 1.x only)
9+
set -euo pipefail
10+
HERE="$(cd "$(dirname "$0")" && pwd)"
11+
SBT_VERSION="${1:?usage: smoke-test-relocation.sh <sbt-version> <scala-version>}"
12+
SCALA_VERSION="${2:?usage: smoke-test-relocation.sh <sbt-version> <scala-version>}"
13+
RESOLVER="${3:-coursier}"
14+
PLUGIN="$HERE/../../socket-facts.plugin.scala"
15+
PROJECT="$HERE/relocation"
16+
RECORDS="$PROJECT/records.tsv"
17+
# shellcheck source=SCRIPTDIR/../compat-cache.sh
18+
. "$HERE/../compat-cache.sh"
19+
IVY="$SOCKET_COMPAT_CACHE/ivy2"
20+
export COURSIER_CACHE="$SOCKET_COMPAT_CACHE/coursier"
21+
22+
bash "$HERE/../make-stub-repo.sh" "$PROJECT/localrepo" \
23+
'demo.old:moved:1.0>demo.ext:moved:1.0' \
24+
'demo.ext:moved:1.0' \
25+
'demo.ext:moved:1.1' \
26+
'demo.ext:wrapper:1.0+demo.old:moved:1.0@test'
27+
rm -rf "$IVY/cache/demo.ext" "$IVY/cache/demo.old"
28+
29+
GB="$(mktemp -d)/global-base"
30+
mkdir -p "$GB/plugins"
31+
cp "$PLUGIN" "$GB/plugins/SocketFactsPlugin.scala"
32+
mkdir -p "$PROJECT/project"
33+
echo "sbt.version=$SBT_VERSION" > "$PROJECT/project/build.properties"
34+
echo "scalaVersion in ThisBuild := \"$SCALA_VERSION\"" > "$PROJECT/scala-version.sbt"
35+
if [ "$RESOLVER" = ivy ]; then
36+
echo 'useCoursier in ThisBuild := false' >> "$PROJECT/scala-version.sbt"
37+
fi
38+
rm -rf "$RECORDS" "$PROJECT/target" "$PROJECT/project/target" "$PROJECT"/*/target
39+
40+
echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION) (relocation, $RESOLVER)"
41+
( cd "$PROJECT" && sbt -Dsbt.global.base="$GB" -Dsbt.server.autostart=false \
42+
-Dsbt.ivy.home="$IVY" -Dsocket.recordsFile="$RECORDS" --batch socketFacts )
43+
44+
python3 - "$RECORDS" <<'PY'
45+
import sys
46+
rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()]
47+
root_of = {r[1]: (r[2], r[3]) for r in rows if r[0] == 'root'}
48+
# Coursier leaves the relocating module out, so the target hangs under its declarer; Ivy keeps it as
49+
# an artifact-less node between the two, which counts as the declarer's edge.
50+
moved = {'core': 'demo.ext:moved:jar:1.0', 'app': 'demo.ext:moved:jar:1.1'}
51+
relocating, wrapper = 'demo.old:moved:1.0', 'demo.ext:wrapper:jar:1.0'
52+
errors = []
53+
for rid, (project, config) in root_of.items():
54+
nodes = {r[2]: r for r in rows if r[0] == 'node' and r[1] == rid}
55+
target = moved.get(project)
56+
if target not in nodes:
57+
continue
58+
parents_of = lambda c: {r[2] for r in rows if r[0] == 'edge' and r[1] == rid and r[3] == c}
59+
parents, direct = parents_of(target), nodes[target][8] == '1'
60+
if relocating in parents:
61+
parents = (parents - {relocating}) | parents_of(relocating)
62+
direct = direct or nodes[relocating][8] == '1'
63+
if project == 'core' and not direct:
64+
errors.append(f"core/{config}: {target} not direct")
65+
if project == 'app' and parents != {'core'}:
66+
errors.append(f"app/{config}: {target} parents {sorted(parents)} != ['core']")
67+
if wrapper in parents:
68+
errors.append(f"{project}/{config}: {wrapper} made a parent of {target}")
69+
for project, target in moved.items():
70+
if not any(r[0] == 'node' and r[2] == target and root_of[r[1]][0] == project for r in rows):
71+
errors.append(f"{target} missing from {project}")
72+
if errors:
73+
print("FAIL:")
74+
for e in errors: print(" -", e)
75+
sys.exit(1)
76+
print(f"PASS (relocation): demo.ext:moved direct in core, under core in app (overridden), never under {wrapper}")
77+
PY
78+
79+
python3 "$HERE/../assert-reachability.py" "$RECORDS"

‎src/commands/manifest/scripts/test/sbt-compat/smoke-test.sh‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
# - tool appears in a prod root, harness only in non-prod roots -> the assembler's dev flag;
99
# - every component is reachable from a direct dependency of its own root (no orphaned components);
1010
# - both get an on-disk jar `file` record under -Dsocket.withFiles;
11+
# - a Maven relocation's target, declared directly (demo.old:moved) or through a dependency
12+
# (demo.old:legacy, from tool), stays in the graph although coursier leaves its caller out of the report;
1113
# - a source in the base dir is a `projectSrc` file, never the base dir itself.
1214
#
1315
# 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"
2729
IVY="$SOCKET_COMPAT_CACHE/ivy2"
2830
export COURSIER_CACHE="$SOCKET_COMPAT_CACHE/coursier"
2931

30-
bash "$HERE/../make-stub-repo.sh" "$PROJECT/localrepo" 'demo.ext:tool:1.0' 'demo.ext:harness:1.0'
32+
bash "$HERE/../make-stub-repo.sh" "$PROJECT/localrepo" \
33+
'demo.ext:tool:1.0+demo.old:legacy:1.0' \
34+
'demo.old:legacy:1.0>demo.ext:legacy:1.0' \
35+
'demo.ext:legacy:1.0' \
36+
'demo.old:moved:1.0>demo.ext:moved:1.0' \
37+
'demo.ext:moved:1.0' \
38+
'demo.ext:harness:1.0'
3139
# scala-library and friends stay cached between runs; the stubs never do, so every run has to
3240
# resolve them from the repo just generated. Only Ivy caches them — coursier reads a `file:`
3341
# repository in place.
@@ -72,6 +80,9 @@ if harness in nodes and any(roots.get(rid) for rid in nodes[harness]):
7280
def has_jar(cid): return any(p.endswith('.jar') for p in files.get(cid, ()))
7381
for cid in (tool, harness):
7482
if not has_jar(cid): errors.append(f"{cid} missing materialized jar under --with-files: {files.get(cid)}")
83+
for cid in ('demo.ext:legacy:jar:1.0', 'demo.ext:moved:jar:1.0'):
84+
if cid not in nodes: errors.append(f"relocation target missing: {cid}")
85+
elif not any(roots.get(rid) for rid in nodes[cid]): errors.append(f"relocation target {cid} not in a prod root")
7586
if os.path.join(base, 'Base.scala') not in srcs: errors.append(f"base-dir source Base.scala not a projectSrc: {sorted(srcs)}")
7687
if base in srcs: errors.append("base dir emitted as a projectSrc root")
7788

0 commit comments

Comments
 (0)