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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- A Gradle dependency whose variant is ambiguous in every configuration of its project now fails the run instead of quietly dropping out of the scan.
- Gradle dependencies that fail dependency verification, or whose jars cannot be fetched, are reported instead of passed on as healthy.
- Failed Gradle project dependencies are reported instead of silently dropping everything they pull in, and failures name the version that actually failed.
- sbt dependencies whose jars coursier skips or Ivy fails to download are reported instead of passed off as POM-only modules, and POM-only, war and aar modules keep their real shape.

## [1.10.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.10.0) - 2026-10-22

Expand Down
239 changes: 211 additions & 28 deletions src/commands/manifest/scripts/socket-facts.plugin.scala

Large diffs are not rendered by default.

15 changes: 9 additions & 6 deletions src/commands/manifest/scripts/test/run-compat.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ GRADLE_MATRIX=("1.12 8" "2.14.1 8" "3.3 8" "6.2.2 8" "8.10.2 17" "9.2.1 21")
# 3.2.5 is the oldest, 4.x the newest. Resolution goes through Maven's own
# ProjectDependenciesResolver, so both ends are load-bearing, not decorative.
MAVEN_MATRIX=("3.2.5 8" "3.6.3 11" "3.8.8 17" "3.9.9 17" "4.0.0-rc-6 21")
SBT_MATRIX=("0.13.18 8 2.10.7" "1.4.9 11 2.12.20" "1.6.2 17 2.12.20" "1.9.9 17 2.12.20")
# The `ivy` row runs sbt 1.x with `useCoursier := false`, the other resolver path; failure suite only.
SBT_MATRIX=("0.13.18 8 2.10.7" "1.4.9 11 2.12.20" "1.6.2 17 2.12.20" "1.9.9 17 2.12.20" "1.9.9 17 2.12.20 ivy")

# Select a JDK for the given Java major: use $JDK<major> if set, else current java.
use_jdk() {
Expand Down Expand Up @@ -103,12 +104,14 @@ run_sbt() {
for row in "${SBT_MATRIX[@]}"; do
# shellcheck disable=SC2086
set -- $row
local ver="$1" java="$2" scala="$3"
echo "== sbt $ver / scala $scala (wants JDK $java) =="
local ver="$1" java="$2" scala="$3" resolver="${4:-}"
echo "== sbt $ver / scala $scala${resolver:+ / $resolver} (wants JDK $java) =="
use_jdk "$java"
bash "$HERE/sbt-compat/smoke-test.sh" "$ver" "$scala"
bash "$HERE/sbt-compat/smoke-test-workspaces.sh" "$ver" "$scala"
bash "$HERE/sbt-compat/smoke-test-failure.sh" "$ver" "$scala"
if [ -z "$resolver" ]; then
bash "$HERE/sbt-compat/smoke-test.sh" "$ver" "$scala"
bash "$HERE/sbt-compat/smoke-test-workspaces.sh" "$ver" "$scala"
fi
bash "$HERE/sbt-compat/smoke-test-failure.sh" "$ver" "$scala" ${resolver:+"$resolver"}
done
}

Expand Down
5 changes: 5 additions & 0 deletions src/commands/manifest/scripts/test/sbt-compat/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,8 @@ failure/target/
failure/project/
failure/scala-version.sbt
failure/records.tsv
artifact-failure/target/
artifact-failure/*/target/
artifact-failure/project/
artifact-failure/scala-version.sbt
artifact-failure/records.tsv
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Artifact-failure fixture for ../../../socket-facts.plugin.scala: every module resolves, but ok has no
// tests-classifier jar, so the only failure is an artifact that names no module. app depends on core,
// whose update sbt also runs for app. Reuses ../failure/localrepo, generated by ../smoke-test-failure.sh.
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}/../failure/localrepo"
) ++ resolvers.value
)

lazy val core = (project in file("core"))
.settings(stubs)
.settings(
libraryDependencies += "demo.fail" % "ok" % "1.0",
libraryDependencies += "demo.fail" % "ok" % "1.0" classifier "tests"
)

lazy val app = (project in file("app")).settings(stubs).dependsOn(core)
13 changes: 10 additions & 3 deletions src/commands/manifest/scripts/test/sbt-compat/failure/build.sbt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Failure fixture for ../../../socket-facts.plugin.scala: ok resolves; lib and testlib name a parent POM
// that does not exist; ghost does not exist at all. Generated into `localrepo` by
// ../smoke-test-failure.sh. Uses the `in ThisBuild` form so it parses on sbt 0.13 and 1.x.
// that does not exist; ghost does not exist at all; nojar has a POM but no jar; pomonly is
// `<packaging>pom</packaging>`, and so is doctype, whose POM declares a DOCTYPE; pkwar is a war with no
// jar; ok has no tests-classifier jar. Generated into `localrepo` by ../smoke-test-failure.sh. Uses the
// `in ThisBuild` form so it parses on sbt 0.13 and 1.x.
organization in ThisBuild := "demo"
version in ThisBuild := "0.1.0"

Expand All @@ -15,5 +17,10 @@ lazy val root = (project in file("."))
libraryDependencies += "demo.fail" % "ok" % "1.0",
libraryDependencies += "demo.fail" % "lib" % "1.0",
libraryDependencies += "demo.fail" % "testlib" % "1.0" % Test,
libraryDependencies += "demo.fail" % "ghost" % "1.0"
libraryDependencies += "demo.fail" % "ghost" % "1.0",
libraryDependencies += "demo.fail" % "nojar" % "1.0",
libraryDependencies += "demo.fail" % "pomonly" % "1.0",
libraryDependencies += "demo.fail" % "doctype" % "1.0",
libraryDependencies += "demo.fail" % "pkwar" % "1.0",
libraryDependencies += "demo.fail" % "ok" % "1.0" % Test classifier "tests"
)
188 changes: 184 additions & 4 deletions src/commands/manifest/scripts/test/sbt-compat/smoke-test-failure.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,23 @@
# the declared dependencies whose parent POM is missing (lib, testlib), coursier (sbt 1.3+) names the
# missing parent POM itself; both name ghost. Each failure's detail is its own module's message.
#
# Usage: smoke-test-failure.sh <sbt-version> <scala-version>
# A second run with files: a jar that cannot be fetched (nojar; lib/testlib's under coursier; ok's
# tests classifier, test-scoped) must be reported and marked `unfetched` under its full coordinate,
# in the configurations that requested it, while pomonly (and under coursier doctype, whose POM
# declares a DOCTYPE) stays an artifactless module and pkwar keeps its war. Coursier skips a missing
# jar silently and lists one for every Maven module whatever its packaging, without a cause; Ivy's
# own resolution report names what it failed to download, and why.
#
# Then ../artifact-failure, whose only failure is ok's missing tests jar, without and with files: an
# artifact failure names no module, yet sbt's update fails on it, so it must be reported in both runs
# and must not make core or its dependent app unscannable.
#
# Usage: smoke-test-failure.sh <sbt-version> <scala-version> [coursier|ivy] (ivy: sbt 1.x only)
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
SBT_VERSION="${1:?usage: smoke-test-failure.sh <sbt-version> <scala-version>}"
SCALA_VERSION="${2:?usage: smoke-test-failure.sh <sbt-version> <scala-version>}"
RESOLVER="${3:-coursier}"
PLUGIN="$HERE/../../socket-facts.plugin.scala"
PROJECT="$HERE/failure"
RECORDS="$PROJECT/records.tsv"
Expand All @@ -34,6 +46,45 @@ for lib in lib testlib; do
</project>
POM
done
mkdir -p "$REPO/demo/fail/nojar/1.0" "$REPO/demo/fail/pomonly/1.0" "$REPO/demo/fail/pkwar/1.0" "$REPO/demo/fail/doctype/1.0"
cat > "$REPO/demo/fail/pkwar/1.0/pkwar-1.0.pom" <<'POM'
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>demo.fail</groupId>
<artifactId>pkwar</artifactId>
<version>1.0</version>
<packaging>war</packaging>
</project>
POM
cp "$REPO/demo/fail/ok/1.0/ok-1.0.jar" "$REPO/demo/fail/pkwar/1.0/pkwar-1.0.war"
cat > "$REPO/demo/fail/nojar/1.0/nojar-1.0.pom" <<'POM'
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>demo.fail</groupId>
<artifactId>nojar</artifactId>
<version>1.0</version>
</project>
POM
cat > "$REPO/demo/fail/pomonly/1.0/pomonly-1.0.pom" <<'POM'
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>demo.fail</groupId>
<artifactId>pomonly</artifactId>
<version>1.0</version>
<packaging>pom</packaging>
</project>
POM
cat > "$REPO/demo/fail/doctype/1.0/doctype-1.0.pom" <<'POM'
<?xml version="1.0"?>
<!DOCTYPE project>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>demo.fail</groupId>
<artifactId>doctype</artifactId>
<version>1.0</version>
<packaging>pom</packaging>
</project>
POM
rm -rf "$IVY/cache/demo.fail"

GB="$(mktemp -d)/global-base"
Expand All @@ -43,12 +94,20 @@ 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"

run() {
local dir="$1"
shift
( cd "$dir" && sbt -Dsbt.global.base="$GB" -Dsbt.server.autostart=false \
-Dsbt.ivy.home="$IVY" "$@" --batch socketFacts )
}

echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION) (failure)"
( cd "$PROJECT" && sbt -Dsbt.global.base="$GB" -Dsbt.server.autostart=false \
-Dsbt.ivy.home="$IVY" \
-Dsocket.recordsFile="$RECORDS" --batch socketFacts )
run "$PROJECT" -Dsocket.recordsFile="$RECORDS"

python3 - "$RECORDS" <<'PY'
import sys
Expand Down Expand Up @@ -84,3 +143,124 @@ if errors:
sys.exit(1)
print(f"PASS (failure): reported {sorted(failed)}; ok still emitted")
PY

rm -rf "$RECORDS" "$PROJECT/target" "$PROJECT/project/target"
echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION) (failure, with files)"
run "$PROJECT" -Dsocket.withFiles=true -Dsocket.recordsFile="$RECORDS"

# sbt resolves with coursier by default from 1.3.
IVY_RESOLVER=false
case "$RESOLVER:$SBT_VERSION" in
ivy:*|*:0.*|*:1.[0-2].*) IVY_RESOLVER=true ;;
esac
python3 - "$RECORDS" "$IVY_RESOLVER" <<'PY'
import sys
rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()]
ivy = sys.argv[2] == 'true'
nodes = {r[2]: r for r in rows if r[0] == 'node'}
unfetched = {r[2] for r in rows if r[0] == 'unfetched'}
failed = {r[1] for r in rows if r[0] == 'failure'}
config_of = {r[1]: r[3] for r in rows if r[0] == 'root'}
unfetched_configs = {(r[2], config_of[r[1]]) for r in rows if r[0] == 'unfetched'}
files = {r[2] for r in rows if r[0] == 'file'}
errors = []

# Ivy's POM reader declares a DOCTYPE of its own, so it fails on doctype's; coursier reads it.
if ivy and 'demo.fail:doctype:1.0' not in failed:
errors.append(f"Ivy cannot read doctype's POM, so it must be reported; failures={sorted(failed)}")
for name in ('pomonly',) if ivy else ('pomonly', 'doctype'):
if f'demo.fail:{name}:1.0' not in nodes:
errors.append(f"{name} is pom-packaged, so it must be emitted without an artifact; nodes={sorted(nodes)}")
if any(name in u for u in unfetched) or f'demo.fail:{name}:1.0' in failed:
errors.append(f"{name} has no jar to fetch; unfetched={sorted(unfetched)} failures={sorted(failed)}")
for name in ('nojar', 'ok'):
if f'demo.fail:{name}:1.0' not in failed:
errors.append(f"{name}'s jar cannot be fetched, so it must be reported; failures={sorted(failed)}")
if 'demo.fail:nojar:jar:1.0' not in unfetched:
errors.append(f"nojar must be emitted as an unfetched jar; unfetched={sorted(unfetched)}")
if 'demo.fail:ok:jar:1.0' not in files:
errors.append("ok's own jar resolved, so its missing tests jar must not cost it its file")
tests_configs = {c for (u, c) in unfetched_configs if u == 'demo.fail:ok:jar:tests:1.0'}
if not tests_configs or any('test' not in c.lower() for c in tests_configs):
errors.append(f"ok's tests jar is requested only in Test, so only test configs may miss it: {sorted(tests_configs)}")
for name in ('nojar', 'lib', 'testlib'):
if f'demo.fail:{name}:1.0' in nodes:
errors.append(f"{name} has a jar-packaged POM, so it must not look artifactless")
if f'demo.fail:{name}:jar:1.0' in nodes and f'demo.fail:{name}:jar:1.0' not in unfetched:
errors.append(f"{name} is emitted without its jar, so it must be marked unfetched; unfetched={sorted(unfetched)}")
if 'demo.fail:pkwar:war:1.0' not in files:
errors.append(f"pkwar's war resolved, so it must keep its file; nodes={sorted(n for n in nodes if 'pkwar' in n)}")
if any('pkwar' in f for f in failed):
errors.append(f"pkwar's packaging is war, so a missing jar is expected; failures={sorted(failed)}")
details = [r[2] for r in rows if r[0] == 'failure' and r[1] == 'demo.fail:nojar:1.0']
# Ivy's report names the cause; coursier's update report does not, so none may be claimed.
expected_cause = 'not found' if ivy else 'could not be fetched'
if len(set(details)) != 1 or expected_cause not in details[0]:
errors.append(f"nojar's jar is absent, so it must be reported once as {expected_cause!r}: {sorted(set(details))}")
stray = unfetched - {f'demo.fail:{n}:jar:1.0' for n in ('nojar', 'lib', 'testlib')} - {'demo.fail:ok:jar:tests:1.0'}
if stray:
errors.append(f"only the jars that are missing may be unfetched: {sorted(stray)}")

if errors:
print("FAIL:")
for e in errors:
print(" -", e)
sys.exit(1)
print(f"PASS (failure, with files): unfetched {sorted(unfetched)}; pomonly artifactless")
PY

ARTIFACT="$HERE/artifact-failure"
mkdir -p "$ARTIFACT/project"
echo "sbt.version=$SBT_VERSION" > "$ARTIFACT/project/build.properties"
cp "$PROJECT/scala-version.sbt" "$ARTIFACT/scala-version.sbt"
rm -rf "$ARTIFACT/records.tsv" "$ARTIFACT/target" "$ARTIFACT"/*/target "$ARTIFACT/project/target"
echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION) (artifact failure)"
run "$ARTIFACT" -Dsocket.recordsFile="$ARTIFACT/records.tsv"

python3 - "$ARTIFACT/records.tsv" <<'PY'
import sys
rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()]
unscannable = [r[1:] for r in rows if r[0] == 'unscannable']
failed = {r[1] for r in rows if r[0] == 'failure'}
errors = []
if unscannable:
errors.append(f"a missing artifact names no module, but must not make a project unscannable: {unscannable}")
if failed != {'demo.fail:ok:1.0'}:
errors.append(f"sbt's update fails on ok's missing tests jar, so it must be reported without files too; failures={sorted(failed)}")
if errors:
print("FAIL:")
for e in errors:
print(" -", e)
sys.exit(1)
print("PASS (artifact failure): ok's missing tests jar reported without files")
PY

rm -rf "$ARTIFACT/records.tsv"
echo "+ sbt $SBT_VERSION (scala $SCALA_VERSION) (artifact failure, with files)"
run "$ARTIFACT" -Dsocket.withFiles=true -Dsocket.recordsFile="$ARTIFACT/records.tsv"

python3 - "$ARTIFACT/records.tsv" <<'PY'
import sys
rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()]
unscannable = [r[1:] for r in rows if r[0] == 'unscannable']
project_of = {r[1]: r[2] for r in rows if r[0] == 'root'}
unfetched_by = {}
for r in rows:
if r[0] == 'unfetched':
unfetched_by.setdefault(project_of[r[1]], set()).add(r[2])
failed = {r[1] for r in rows if r[0] == 'failure'}
errors = []
if unscannable:
errors.append(f"a missing artifact names no module, but must not make a project unscannable: {unscannable}")
for p in ('core', 'app'):
if unfetched_by.get(p) != {'demo.fail:ok:jar:tests:1.0'}:
errors.append(f"{p} reaches ok's missing tests jar, so only it may be unfetched: {sorted(unfetched_by.get(p, ()))}")
if failed != {'demo.fail:ok:1.0'}:
errors.append(f"only ok's tests jar is missing; failures={sorted(failed)}")
if errors:
print("FAIL:")
for e in errors:
print(" -", e)
sys.exit(1)
print("PASS (artifact failure, with files): ok's tests jar unfetched in core and app; nothing unscannable")
PY
Loading