diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7002ef4e..2e0007ac5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Fixed
- When `dotnet restore` fails in one .NET project, the projects it may have skipped as up to date are no longer reported as not restored.
+- Maven Socket facts now include every dependency edge Maven resolves, as Gradle and sbt facts do, so dependency chains show each path to a package. A dependency Maven puts on the runtime classpath is no longer marked as a dev dependency because Maven kept it under a test or provided dependency.
+- With `--ignore-unresolved`, a Maven module with unresolvable version conflicts no longer adds a partial dependency list.
## [1.14.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.14.1) - 2026-11-06
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 93c8ad272..14e098e1e 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
@@ -9,9 +9,13 @@
import org.apache.maven.project.DependencyResolutionResult;
import org.apache.maven.project.MavenProject;
import org.apache.maven.project.ProjectDependenciesResolver;
+import org.eclipse.aether.DefaultRepositorySystemSession;
+import org.eclipse.aether.RepositoryException;
import org.eclipse.aether.RepositorySystem;
import org.eclipse.aether.RequestTrace;
import org.eclipse.aether.artifact.Artifact;
+import org.eclipse.aether.collection.DependencyGraphTransformationContext;
+import org.eclipse.aether.collection.DependencyGraphTransformer;
import org.eclipse.aether.graph.Dependency;
import org.eclipse.aether.graph.DependencyFilter;
import org.eclipse.aether.graph.DependencyNode;
@@ -46,8 +50,9 @@
/**
* Emits each reactor module's resolved dependency graph as line-protocol records for the TS assembler
- * (same contract as the Gradle/SBT scripts; no JSON/hashing here). Per module: a prod root
- * (compile/runtime/system) and a dev root (test/provided). A reactor module becomes a component only
+ * (same contract as the Gradle/SBT scripts; no JSON/hashing here). Per module: a test root with the
+ * whole resolution and a compile root with the nodes kept with a prod scope (compile/runtime/system),
+ * each with the resolved edges between its own nodes. A reactor module becomes a component only
* where another depends on it, by its bare {@code groupId:artifactId:version} id.
*
*
Resolution goes through {@link ProjectDependenciesResolver} — the same component Maven's own
@@ -79,6 +84,8 @@ public static final class Options {
private static final String ARTIFACT_PROPERTY_TYPE = "type";
// ConflictResolver.NODE_DATA_WINNER, inlined so nothing here needs maven-resolver-util.
private static final String NODE_DATA_CONFLICT_WINNER = "conflict.winner";
+ // TransformationContextKeys.CONFLICT_IDS, inlined for the same reason.
+ private static final String CONTEXT_CONFLICT_IDS = "conflictIds";
private final ProjectDependenciesResolver dependenciesResolver;
private final RepositorySystem repositorySystem;
@@ -161,10 +168,8 @@ public void run(MavenSession session, List reactor, File rootDir,
if (SocketSupport.isExcludedDir(excludeBase, module.getBasedir().toPath(), excludes)) continue;
Map nodes = new LinkedHashMap<>();
Set directIds = new HashSet<>();
- // Which direct dependencies are prod-scoped: seeds the prod/dev root split (see emitModuleRoots).
- Set directProdIds = new HashSet<>();
- collectModule(session, module, passingScopes, reactorGavs, populateGavs, opts, nodes, directIds, directProdIds, failures, unscannable);
- rootIdx = emitModuleRoots(lines, rootIdx, projectKey(module), nodes, directIds, directProdIds);
+ collectModule(session, module, passingScopes, reactorGavs, populateGavs, opts, nodes, directIds, failures, unscannable);
+ rootIdx = emitModuleRoots(lines, rootIdx, projectKey(module), nodes, directIds);
}
for (Failure f : failures) rec(lines, "failure", f.coord, f.detail, f.config, f.project);
@@ -190,7 +195,6 @@ private void collectModule(
Options opts,
Map nodes,
Set directIds,
- Set directProdIds,
Set failures,
Set unscannable) {
String moduleCoord = module.getGroupId() + ":" + module.getArtifactId() + ":" + module.getVersion();
@@ -198,9 +202,11 @@ private void collectModule(
DependencyResolutionException thrown = null;
DependencyFilter resolutionFilter = materializationFilter(opts, passingScopes, reactorGavs, populateGavs);
observer.reset();
+ EdgeRecorder recorder = new EdgeRecorder(session.getRepositorySession().getDependencyGraphTransformer());
+ DefaultRepositorySystemSession recording = new DefaultRepositorySystemSession(session.getRepositorySession());
+ recording.setDependencyGraphTransformer(recorder);
try {
- DependencyResolutionRequest request =
- new DefaultDependencyResolutionRequest(module, session.getRepositorySession());
+ DependencyResolutionRequest request = new DefaultDependencyResolutionRequest(module, recording);
request.setResolutionFilter(resolutionFilter);
result = dependenciesResolver.resolve(request);
} catch (DependencyResolutionException e) {
@@ -209,13 +215,16 @@ private void collectModule(
thrown = e;
result = e.getResult();
if (result == null) {
- unscannable.add(new Unscannable(rootMessage(e), moduleCoord));
- log.warn("[socket-facts] could not resolve dependencies for " + moduleCoord + ": " + rootMessage(e));
+ reportUnscannable(unscannable, moduleCoord, rootMessage(e));
return;
}
}
boolean reported = false;
- DependencyNode graph = result.getDependencyGraph();
+ // Null when the transformer threw (the result then holds only the collected candidates) or the
+ // recorder never saw the graph Maven returned.
+ Map> edges = recorder.edges();
+ DependencyNode returned = result.getDependencyGraph();
+ DependencyNode graph = edges != null && edges.containsKey(returned) ? returned : null;
List collectionFailures = new ArrayList<>();
for (Exception e : result.getCollectionErrors()) {
FailedRequest failed = failedRequest(e);
@@ -251,11 +260,10 @@ private void collectModule(
if (scopes != null) reported |= addFailures(failures, e.getKey(), e.getValue(), scopes, passingScopes, moduleCoord);
}
Set placed = new HashSet<>();
- Set onClasspath = null;
+ Set onClasspath = graph == null ? Collections.emptySet() : edges.keySet();
for (FailedRequest failed : collectionFailures) {
String coord = gav(failed.artifact);
if (scopesByGav.containsKey(coord)) continue;
- if (onClasspath == null) onClasspath = onClasspath(graph);
String scope = absentScope(session, module, failed, onClasspath);
placed.add(coord);
if (scope == null) {
@@ -293,21 +301,18 @@ private void collectModule(
// Fail closed: a throw whose result named nothing must still surface, or an unresolved dependency
// would silently leave the reachability analysis blind to whatever that artifact contains.
if (thrown != null && !reported) {
- unscannable.add(new Unscannable(rootMessage(thrown), moduleCoord));
- log.warn("[socket-facts] could not resolve dependencies for " + moduleCoord + ": " + rootMessage(thrown));
+ reportUnscannable(unscannable, moduleCoord, rootMessage(thrown));
+ }
+ // A transformer that threw is a collection error, reported above. Otherwise Maven's graph bypassed
+ // the recorder, and the module's whole graph would vanish unreported.
+ if (graph == null && !recorder.threw()) {
+ reportUnscannable(unscannable, moduleCoord, "dependencies resolved without the socket-facts graph transformer");
}
if (graph == null) return;
Set visited = new HashSet<>();
- for (DependencyNode child : graph.getChildren()) {
- String id = visit(child, passingScopes, reactorGavs, unresolved, opts, nodes, visited);
- if (id != null) {
- directIds.add(id);
- // A direct dependency's effective scope is its declared scope (Aether's scope selector keeps
- // a depth-1 item's scope), so it reliably seeds the prod/dev split. The node's own `prod` flag
- // does NOT: conflict resolution can keep a node under a dev parent while its effective scope
- // is prod (or vice versa).
- if (isProd(scopeOf(child.getDependency()))) directProdIds.add(id);
- }
+ for (DependencyNode child : edges.get(graph)) {
+ String id = visit(child, edges, passingScopes, reactorGavs, unresolved, opts, nodes, visited);
+ if (id != null) directIds.add(id);
}
for (String id : unfetched) {
Node node = nodes.get(id);
@@ -385,6 +390,11 @@ public boolean accept(DependencyNode node, List parents) {
}
}
+ private void reportUnscannable(Set unscannable, String moduleCoord, String reason) {
+ unscannable.add(new Unscannable(reason, moduleCoord));
+ log.warn("[socket-facts] could not resolve dependencies for " + moduleCoord + ": " + reason);
+ }
+
private void reportUnfetched(
Dependency dep, List errors, String moduleCoord, Set failures, Set unfetched) {
String id = externalId(dep.getArtifact());
@@ -395,6 +405,7 @@ private void reportUnfetched(
private String visit(
DependencyNode dn,
+ Map> edges,
Set passingScopes,
Set reactorGavs,
Set unresolved,
@@ -404,9 +415,6 @@ private String visit(
Dependency dep = dn.getDependency();
Artifact artifact = dn.getArtifact();
if (dep == null || artifact == null) return null;
- // A verbose collect — Maven's -X turns one on — keeps conflict-losing nodes in the graph, tagged
- // with the winner they lost to. Only the winner is on the classpath Maven would build.
- if (dn.getData().get(NODE_DATA_CONFLICT_WINNER) != null) return null;
String scope = scopeOf(dep);
if (!passingScopes.contains(scope)) return null;
@@ -420,13 +428,16 @@ private String visit(
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.
+ // and test-jar share a node but can collect different children, e.g. when one declaration has exclusions.
if (!visited.add(key)) return id;
Node node = internal
? upsert(nodes, id, artifact.getGroupId(), artifact.getArtifactId(), "", "", version, gav)
: upsert(nodes, id, artifact.getGroupId(), artifact.getArtifactId(),
type == null ? "" : type, classifier == null ? "" : classifier, version, "");
+ // A sibling's jar and test-jar share a node, each kept with its own scope.
+ if (isProd(scope)) node.prod = true;
+
// Maven wrote each accepted node's resolved file back onto the node; a reactor module reports its
// own dirs through its `project` record instead of a `file` record.
if (!internal && opts.withFiles) {
@@ -434,8 +445,8 @@ private String visit(
if (file != null) node.files.add(file);
}
- for (DependencyNode child : dn.getChildren()) {
- String childId = visit(child, passingScopes, reactorGavs, unresolved, opts, nodes, visited);
+ for (DependencyNode child : edges.get(dn)) {
+ String childId = visit(child, edges, passingScopes, reactorGavs, unresolved, opts, nodes, visited);
if (childId != null) node.children.add(childId);
}
return id;
@@ -478,45 +489,21 @@ private static Node upsert(
// ---- emission ----
- // Split a module's resolved nodes into a prod root and a dev root. Membership is decided by which
- // direct dependency a node is REACHABLE from, not by the node's own effective scope: Maven's
- // conflict resolution can retain a node under a dev parent while its effective scope is prod (the
- // retained path came from a test/provided dep, but a removed deeper path had a compile scope, so
- // Aether's scope selector unions them to compile). Partitioning by scope would put that node in the
- // prod root while its only parent sits in the dev root, and emitRoot drops the cross-root edge —
- // leaving the node unreachable and non-direct (the "orphaned component" defect). Reachability also
- // keeps edges intra-root, because a node reached from a prod direct dependency stays prod.
- // Empty roots are skipped.
+ // Shaped like Gradle's runtime and test classpaths. An edge from a prod node onto a dev one, such as
+ // onto a dependency the module declares `provided`, lives in the test root only.
private int emitModuleRoots(
- List lines, int rootIdx, String projectKey, Map nodes,
- Set directIds, Set directProdIds) {
- Set prodReachable = reachable(nodes, directProdIds);
+ List lines, int rootIdx, String projectKey, Map nodes, Set directIds) {
Map prod = new LinkedHashMap<>();
- Map dev = new LinkedHashMap<>();
- for (Node n : nodes.values()) (prodReachable.contains(n.id) ? prod : dev).put(n.id, n);
+ for (Node n : nodes.values()) if (n.prod) prod.put(n.id, n);
if (!prod.isEmpty()) {
rootIdx = emitRoot(lines, rootIdx, projectKey, "compile", true, prod, directIds);
}
- if (!dev.isEmpty()) {
- rootIdx = emitRoot(lines, rootIdx, projectKey, "test", false, dev, directIds);
+ if (!nodes.isEmpty()) {
+ rootIdx = emitRoot(lines, rootIdx, projectKey, "test", false, nodes, directIds);
}
return rootIdx;
}
- // Every node id reachable from `seeds` by following retained child edges.
- private static Set reachable(Map nodes, Set seeds) {
- Set seen = new HashSet<>();
- Deque stack = new ArrayDeque<>(seeds);
- while (!stack.isEmpty()) {
- String id = stack.pop();
- if (!seen.add(id)) continue;
- Node node = nodes.get(id);
- if (node == null) continue;
- for (String child : node.children) stack.push(child);
- }
- return seen;
- }
-
private int emitRoot(
List lines, int rootIdx, String projectKey, String config, boolean prod, Map nodeMap, Set directIds) {
String rootId = Integer.toString(rootIdx);
@@ -873,6 +860,98 @@ public int hashCode() {
}
}
+ // Records each node's children before conflict resolution removes the losers: every resolved edge,
+ // including one onto a dependency Maven kept elsewhere, possibly at another version.
+ private static final class EdgeRecorder implements DependencyGraphTransformer {
+ private final DependencyGraphTransformer delegate;
+ private Map> edges;
+ private boolean threw;
+
+ EdgeRecorder(DependencyGraphTransformer delegate) {
+ this.delegate = delegate;
+ }
+
+ /**
+ * The root and each node Maven kept, mapped to its children by every resolved edge, a child that
+ * lost its conflict replaced by the node kept for its conflict id. Null unless the last
+ * call to the transformer, recording included, completed.
+ */
+ Map> edges() {
+ return edges;
+ }
+
+ boolean threw() {
+ return threw;
+ }
+
+ // A RepositoryException is a collection error, so a broken assumption fails this module only.
+ @Override
+ public DependencyNode transformGraph(DependencyNode root, DependencyGraphTransformationContext context)
+ throws RepositoryException {
+ // Resolver 2 may collect again when a DependencyCollectionChecker asks; the last run is the result.
+ edges = null;
+ threw = true;
+ Map> collected = snapshot(root);
+ DependencyNode transformed = delegate.transformGraph(root, context);
+ Map, ?> conflictIds = (Map, ?>) context.get(CONTEXT_CONFLICT_IDS);
+ if (conflictIds == null) throw new RepositoryException("Maven's graph transformer assigned no conflict ids");
+ edges = resolvedEdges(transformed, collected, conflictIds);
+ threw = false;
+ return transformed;
+ }
+
+ private static Map> snapshot(DependencyNode root) {
+ Map> children = new IdentityHashMap<>();
+ // The collector gives nodes of one artifact and context one shared child list.
+ Map, List> copies = new IdentityHashMap<>();
+ Deque todo = new ArrayDeque<>();
+ todo.push(root);
+ while (!todo.isEmpty()) {
+ DependencyNode n = todo.pop();
+ if (children.containsKey(n)) continue;
+ List copy = copies.get(n.getChildren());
+ if (copy == null) {
+ copy = new ArrayList<>(n.getChildren());
+ copies.put(n.getChildren(), copy);
+ todo.addAll(copy);
+ }
+ children.put(n, copy);
+ }
+ return children;
+ }
+
+ private static Map> resolvedEdges(
+ DependencyNode graph, Map> collected, Map, ?> conflictIds)
+ throws RepositoryException {
+ Set classpath = onClasspath(graph);
+ Map