diff --git a/CHANGELOG.md b/CHANGELOG.md
index 54bb3b26be..dd2fd26301 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Fixed
- `--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.
## [1.12.2](https://github.com/SocketDev/socket-cli/releases/tag/v1.12.2) - 2026-10-30
diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java
index 4de0d60610..93c8ad272f 100644
--- a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java
+++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java
@@ -416,14 +416,12 @@ private String visit(
String type = artifact.getProperty(ARTIFACT_PROPERTY_TYPE, artifact.getExtension());
String classifier = artifact.getClassifier();
String version = artifact.getBaseVersion();
- String id = internal
- ? SocketSupport.bareId(artifact.getGroupId(), artifact.getArtifactId(), version)
- : externalId(artifact);
-
- // One walk per node per module traversal: a shared subtree reached via another edge is already
- // fully recorded (node, children, resolved file), so hand back the id without re-descending.
- // Keeps reconverging graphs linear.
- if (!visited.add(id)) return id;
+ String key = externalId(artifact);
+ String id = internal ? SocketSupport.bareId(artifact.getGroupId(), artifact.getArtifactId(), version) : key;
+
+ // One walk per artifact: a shared subtree is already recorded. Keyed by coordinate, as a sibling's jar
+ // and test-jar share a node but Maven can keep the sibling's dependencies under either.
+ if (!visited.add(key)) return id;
Node node = internal
? upsert(nodes, id, artifact.getGroupId(), artifact.getArtifactId(), "", "", version, gav)
diff --git a/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml b/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml
index f1b947ff63..10d2d6fbe4 100644
--- a/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml
+++ b/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml
@@ -8,10 +8,13 @@
app
-
+
demo
- lib
+ mid
1.0
@@ -43,5 +46,12 @@
conflict-prod
1.0
+
+ demo
+ lib
+ 1.0
+ test-jar
+ test
+
diff --git a/src/commands/manifest/scripts/test/maven-compat/project/lib/pom.xml b/src/commands/manifest/scripts/test/maven-compat/project/lib/pom.xml
index f16c76bf3b..37cba8823a 100644
--- a/src/commands/manifest/scripts/test/maven-compat/project/lib/pom.xml
+++ b/src/commands/manifest/scripts/test/maven-compat/project/lib/pom.xml
@@ -7,4 +7,25 @@
1.0
lib
+
+
+ demo.ext
+ libdep
+ 1.0
+
+
+
+
+
+ maven-jar-plugin
+
+
+
+ test-jar
+
+
+
+
+
+
diff --git a/src/commands/manifest/scripts/test/maven-compat/project/mid/pom.xml b/src/commands/manifest/scripts/test/maven-compat/project/mid/pom.xml
new file mode 100644
index 0000000000..8feda9593c
--- /dev/null
+++ b/src/commands/manifest/scripts/test/maven-compat/project/mid/pom.xml
@@ -0,0 +1,17 @@
+
+
+ 4.0.0
+
+ demo
+ root
+ 1.0
+
+ mid
+
+
+ demo
+ lib
+ 1.0
+
+
+
diff --git a/src/commands/manifest/scripts/test/maven-compat/project/mid/src/main/java/demo/Mid.java b/src/commands/manifest/scripts/test/maven-compat/project/mid/src/main/java/demo/Mid.java
new file mode 100644
index 0000000000..5d5b917c99
--- /dev/null
+++ b/src/commands/manifest/scripts/test/maven-compat/project/mid/src/main/java/demo/Mid.java
@@ -0,0 +1,4 @@
+package demo;
+
+public class Mid {
+}
diff --git a/src/commands/manifest/scripts/test/maven-compat/project/pom.xml b/src/commands/manifest/scripts/test/maven-compat/project/pom.xml
index 1bbca3458c..c9dc686a95 100644
--- a/src/commands/manifest/scripts/test/maven-compat/project/pom.xml
+++ b/src/commands/manifest/scripts/test/maven-compat/project/pom.xml
@@ -7,6 +7,7 @@
pom
lib
+ mid
app
diff --git a/src/commands/manifest/scripts/test/maven-compat/smoke-test-workspaces.sh b/src/commands/manifest/scripts/test/maven-compat/smoke-test-workspaces.sh
index 209ecb7209..116e871f15 100755
--- a/src/commands/manifest/scripts/test/maven-compat/smoke-test-workspaces.sh
+++ b/src/commands/manifest/scripts/test/maven-compat/smoke-test-workspaces.sh
@@ -45,10 +45,10 @@ if tool != 'maven':
errors.append(f"meta tool {tool!r} != 'maven'")
if build_root != sys.argv[2]:
errors.append(f"buildRoot {build_root!r} != {sys.argv[2]!r}")
-expected_build_files = {'pom.xml', 'lib/pom.xml', 'app/pom.xml'}
+expected_build_files = {'pom.xml', 'lib/pom.xml', 'mid/pom.xml', 'app/pom.xml'}
if build_files != expected_build_files:
errors.append(f"projectBuild files {sorted(build_files)} != {sorted(expected_build_files)}")
-expected = {'root', 'lib', 'app'}
+expected = {'root', 'lib', 'mid', 'app'}
if set(projects) != expected:
errors.append(f"expected reactor modules {sorted(expected)}, got {sorted(projects)}")
for artifact in expected & set(projects):
diff --git a/src/commands/manifest/scripts/test/maven-compat/smoke-test.sh b/src/commands/manifest/scripts/test/maven-compat/smoke-test.sh
index f5e55de2b5..be8dac3f40 100755
--- a/src/commands/manifest/scripts/test/maven-compat/smoke-test.sh
+++ b/src/commands/manifest/scripts/test/maven-compat/smoke-test.sh
@@ -8,6 +8,8 @@
# inter-module edge lines up with its `project` record);
# - resolves a scope conflict so the retained node stays reachable from its parent root;
# - every component is reachable from a direct dependency of its own root (no orphaned components);
+# - keeps the dependencies Maven leaves only under a sibling module's test-jar (app reaches
+# demo.ext:libdep under lib's test-jar node, after lib's jar node through mid);
# - materializes resolved external jars under -Dsocket.withFiles;
# - scopes that materialization to -Dsocket.populateFilesFor (a newline-delimited GAV file).
#
@@ -36,7 +38,8 @@ bash "$HERE/../make-stub-repo.sh" "$STUBS" \
'demo.ext:harness-core:1.0' \
'demo.ext:conflict-test:1.0+demo.ext:shared:1.0' \
'demo.ext:conflict-prod:1.0+demo.ext:shared:1.0' \
- 'demo.ext:shared:1.0'
+ 'demo.ext:shared:1.0' \
+ 'demo.ext:libdep:1.0'
# Maven's own plugin closure stays cached between runs; the stubs never do, so every run has to
# resolve them from the repo just generated.
rm -rf "$M2/demo" "$RECORDS" "$PROJECT"/*/target "$PROJECT"/target
@@ -52,6 +55,14 @@ echo "+ $("$MVN" -v 2>/dev/null | head -1)"
compile )
python3 "$HERE/assert-records.py" "$RECORDS"
+python3 - "$RECORDS" <<'PY'
+import sys
+rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()]
+app = {r[1] for r in rows if r[0] == 'root' and r[2] == 'demo:app:1.0'}
+if not any(r[0] == 'node' and r[1] in app and r[2].startswith('demo.ext:libdep:') for r in rows):
+ print("FAIL: demo.ext:libdep missing from app"); sys.exit(1)
+print("PASS: demo.ext:libdep in app through lib's test-jar")
+PY
python3 "$HERE/../assert-reachability.py" "$RECORDS"
# Second run: scope --with-files to a single GAV and assert ONLY that artifact is materialized.