Skip to content

Commit 7bd93ab

Browse files
authored
fix(manifest): record every resolved Maven dependency edge (#1711)
* fix(manifest): record every resolved Maven dependency edge Maven facts only had the edges that survive conflict resolution, so an edge onto a dependency Maven kept elsewhere (a direct dependency, or a mediated version) was missing, unlike Gradle and sbt facts. The Maven extension now records each node's children before conflict resolution and points every edge at the node Maven kept. Roots follow Gradle's runtime and test classpaths: the test root is the module's whole resolution, the compile root the nodes Maven kept with a prod scope. A dependency Maven widens to compile under a test parent is therefore prod, matching Maven's runtime classpath. A module whose conflict resolution fails has no resolved graph, so it is reported unscannable and contributes no dependencies. * fix(manifest): fail closed when Maven's graph skips the edge recorder Build the resolved edges inside the transformer, so a broken assumption about Maven's graph becomes a collection error that marks only that module unscannable. A module whose graph never passed through the recorder is now reported instead of emitting nothing silently. Walk each artifact once again before touching its node, leave self-edges to the assembler, and exclude libdep from mid's lib dependency so the fixture catches a walk keyed by the shared jar/test-jar id.
1 parent 768711b commit 7bd93ab

11 files changed

Lines changed: 535 additions & 211 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1111

1212
### Fixed
1313
- 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.
14+
- 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.
15+
- With `--ignore-unresolved`, a Maven module with unresolvable version conflicts no longer adds a partial dependency list.
1416

1517
## [1.14.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.14.1) - 2026-11-06
1618

‎src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java‎

Lines changed: 142 additions & 62 deletions
Large diffs are not rendered by default.

‎src/commands/manifest/scripts/test/maven-compat/.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,6 @@ graph-failure/target/
1616
descriptor-failure/records.tsv
1717
descriptor-failure/target/
1818
descriptor-failure/*/target/
19+
unsolvable-conflict/records.tsv
20+
unsolvable-conflict/target/
21+
unsolvable-conflict/localrepo/

‎src/commands/manifest/scripts/test/maven-compat/assert-records.py‎

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,20 @@
66
import sys
77
rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()]
88
tool = None
9-
roots, nodes, files, direct = {}, {}, {}, {}
9+
roots, root_projects, nodes, files, direct, edges = {}, {}, {}, {}, {}, {}
1010
for r in rows:
1111
if r[0] == 'meta': tool = r[1]
12-
elif r[0] == 'root': roots[r[1]] = (r[4] == '1') # rootId -> prod
12+
elif r[0] == 'root':
13+
# rootId to prod and to projectKey.
14+
roots[r[1]] = (r[4] == '1')
15+
root_projects[r[1]] = r[2]
1316
elif r[0] == 'node':
1417
nodes.setdefault(r[2], set()).add(r[1]) # coordId -> {rootId}
1518
if r[8] == '1': direct.setdefault(r[2], set()).add(r[1]) # coordId -> {rootId where direct}
1619
elif r[0] == 'file': files.setdefault(r[2], set()).add(r[3]) # coordId -> {path}
20+
elif r[0] == 'edge':
21+
# (parent, child) to the roots that hold the edge.
22+
edges.setdefault((r[2], r[3]), set()).add(r[1])
1723

1824
errors = []
1925

@@ -61,15 +67,48 @@ def has_jar(cid): return any(p.endswith('.jar') for p in files.get(cid, ()))
6167
elif not in_prod(lib): errors.append("internal module demo:lib not in app's prod root")
6268
elif not direct.get(lib): errors.append("internal module demo:lib not marked direct")
6369

64-
# Scope conflict: Maven keeps demo.ext:shared under the test parent while its effective
65-
# scope is compile, so it must land in the dev root. Reachability (checked by
66-
# ../assert-reachability.py) additionally proves it kept its parent edge.
67-
shared = coord('demo.ext:shared:jar:')
68-
if not shared: errors.append("missing scope-conflict dep demo.ext:shared")
69-
elif in_prod(shared): errors.append("scope-conflict demo.ext:shared wrongly in a prod root")
70+
def edge_roots(parent, child):
71+
return edges.get((parent, child), set())
72+
73+
def edge_pair(parent, child):
74+
"""Both coordinates, or None after reporting the missing ones."""
75+
found = (coord(parent), coord(child))
76+
missing = [p for p, c in zip((parent, child), found) if not c]
77+
if missing:
78+
errors.append(f"missing {', '.join(m.rstrip(':') for m in missing)}")
79+
return None
80+
return found
81+
82+
# Scope conflict: Maven keeps demo.ext:shared under the test parent but selects compile, the
83+
# scope conflict-prod's path gives it, so it is on the runtime classpath. Its edge from
84+
# conflict-prod, onto the copy Maven kept under the test parent, keeps it reachable in the prod root.
85+
pair = edge_pair('demo.ext:conflict-prod:jar:', 'demo.ext:shared:jar:')
86+
if pair:
87+
if not in_prod(pair[1]): errors.append("scope-conflict demo.ext:shared not in a prod root")
88+
elif not any(roots.get(rid) for rid in edge_roots(*pair)):
89+
errors.append("edge demo.ext:conflict-prod -> demo.ext:shared missing from a prod root")
90+
91+
# An edge onto a dependency Maven kept elsewhere: dup-io's dup-util is the direct one.
92+
pair = edge_pair('demo.ext:dup-io:jar:', 'demo.ext:dup-util:jar:')
93+
if pair and not edge_roots(*pair): errors.append("edge demo.ext:dup-io -> demo.ext:dup-util missing")
94+
95+
# A mediated edge: med-io asks for another med-util than mid declares, and points at mid's.
96+
# coord() fails if the med-util version med-io asked for is also emitted.
97+
pair = edge_pair('demo.ext:med-io:jar:', 'demo.ext:med-util:jar:')
98+
if pair and not any(root_projects.get(rid) == 'demo:mid:1.0' for rid in edge_roots(*pair)):
99+
errors.append("edge demo.ext:med-io -> demo.ext:med-util missing from mid")
100+
101+
# An edge across the prod/dev split: container-lib (compile) onto container-api, which app
102+
# declares provided. The edge is kept and container-api stays dev.
103+
pair = edge_pair('demo.ext:container-lib:jar:', 'demo.ext:container-api:jar:')
104+
if pair:
105+
if in_prod(pair[1]): errors.append("provided demo.ext:container-api wrongly in a prod root")
106+
elif not in_prod(pair[0]): errors.append("demo.ext:container-lib not in a prod root")
107+
elif not edge_roots(*pair):
108+
errors.append("edge demo.ext:container-lib -> demo.ext:container-api missing")
70109

71110
if errors:
72111
print("FAIL:")
73112
for e in errors: print(" -", e)
74113
sys.exit(1)
75-
print(f"PASS: tool=maven; {ext} prod+jar; harness/harness-core dev; internal demo:lib (bare id, direct); {shared} dev")
114+
print(f"PASS: tool=maven; {ext} prod+jar; harness/harness-core dev; internal demo:lib (bare id, direct); demo.ext:shared prod; edges onto kept dependencies")

‎src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml‎

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,8 @@
99
<artifactId>app</artifactId>
1010
<dependencies>
1111
<!-- internal reactor modules -> inter-module edges, bare-id components. lib's jar comes in
12-
through mid, deeper than lib's test-jar further down, so Maven keeps demo.ext:libdep (with
13-
its compile scope) under the test-jar node and none under the jar node, which the walk
14-
reaches first. -->
12+
through mid, which excludes demo.ext:libdep, so only lib's test-jar further down brings
13+
libdep, and the walk reaches the jar first. -->
1514
<dependency>
1615
<groupId>demo</groupId>
1716
<artifactId>mid</artifactId>
@@ -31,10 +30,9 @@
3130
<scope>test</scope>
3231
</dependency>
3332
<!-- Scope-conflict pair: both pull demo.ext:shared at the same depth. Because the test
34-
dep is declared first, Maven keeps shared under the test parent but unions the scopes to
35-
compile, so shared's effective scope is prod while its only retained parent is dev. A
36-
scope-based prod/dev split would strand shared in the prod root with no parent edge (an
37-
unreachable, non-direct component); the reachability split keeps it with its parent. -->
33+
dep is declared first, Maven keeps shared under the test parent but selects compile, the
34+
scope conflict-prod's path gives it, so shared is prod and reached through
35+
conflict-prod's edge. -->
3836
<dependency>
3937
<groupId>demo.ext</groupId>
4038
<artifactId>conflict-test</artifactId>
@@ -46,6 +44,29 @@
4644
<artifactId>conflict-prod</artifactId>
4745
<version>1.0</version>
4846
</dependency>
47+
<!-- dup-io depends on dup-util, which Maven keeps as this direct dependency. -->
48+
<dependency>
49+
<groupId>demo.ext</groupId>
50+
<artifactId>dup-util</artifactId>
51+
<version>1.0</version>
52+
</dependency>
53+
<dependency>
54+
<groupId>demo.ext</groupId>
55+
<artifactId>dup-io</artifactId>
56+
<version>1.0</version>
57+
</dependency>
58+
<!-- container-lib (compile) depends on container-api, which Maven keeps as provided. -->
59+
<dependency>
60+
<groupId>demo.ext</groupId>
61+
<artifactId>container-api</artifactId>
62+
<version>1.0</version>
63+
<scope>provided</scope>
64+
</dependency>
65+
<dependency>
66+
<groupId>demo.ext</groupId>
67+
<artifactId>container-lib</artifactId>
68+
<version>1.0</version>
69+
</dependency>
4970
<dependency>
5071
<groupId>demo</groupId>
5172
<artifactId>lib</artifactId>

‎src/commands/manifest/scripts/test/maven-compat/project/mid/pom.xml‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,23 @@
1212
<groupId>demo</groupId>
1313
<artifactId>lib</artifactId>
1414
<version>1.0</version>
15+
<exclusions>
16+
<exclusion>
17+
<groupId>demo.ext</groupId>
18+
<artifactId>libdep</artifactId>
19+
</exclusion>
20+
</exclusions>
21+
</dependency>
22+
<!-- med-io asks for med-util 2.0; Maven mediates it to the 1.0 declared here. -->
23+
<dependency>
24+
<groupId>demo.ext</groupId>
25+
<artifactId>med-util</artifactId>
26+
<version>1.0</version>
27+
</dependency>
28+
<dependency>
29+
<groupId>demo.ext</groupId>
30+
<artifactId>med-io</artifactId>
31+
<version>1.0</version>
1532
</dependency>
1633
</dependencies>
1734
</project>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env bash
2+
# Regression guard: when Maven's conflict resolution throws (hard version ranges no version
3+
# satisfies), Maven resolves no graph for the module. It must be reported unscannable and none of its
4+
# collected dependencies emitted or fetched, however their scopes were left mid-resolution.
5+
#
6+
# Usage: smoke-test-unsolvable-conflict.sh <path-to-mvn> <path-to-extension-jar>
7+
set -euo pipefail
8+
HERE="$(cd "$(dirname "$0")" && pwd)"
9+
MVN="${1:?usage: smoke-test-unsolvable-conflict.sh <mvn> <extension-jar>}"
10+
JAR="${2:?usage: smoke-test-unsolvable-conflict.sh <mvn> <extension-jar>}"
11+
PROJECT="$HERE/unsolvable-conflict"
12+
RECORDS="$PROJECT/records.tsv"
13+
STUBS="$PROJECT/localrepo"
14+
# shellcheck source=SCRIPTDIR/../compat-cache.sh
15+
. "$HERE/../compat-cache.sh"
16+
M2="$SOCKET_COMPAT_CACHE/m2"
17+
18+
bash "$HERE/../make-stub-repo.sh" "$STUBS" \
19+
'demo.unsolvable:left:1.0+demo.unsolvable:shared:[1.0]' \
20+
'demo.unsolvable:right:1.0+demo.unsolvable:shared:[2.0]' \
21+
'demo.unsolvable:harness:1.0+demo.unsolvable:harness-core:1.0' \
22+
'demo.unsolvable:harness-core:1.0' \
23+
'demo.unsolvable:shared:1.0' \
24+
'demo.unsolvable:shared:2.0'
25+
rm -rf "$M2/demo/unsolvable" "$RECORDS"
26+
27+
echo "+ $("$MVN" -v 2>/dev/null | head -1) (unsolvable conflict)"
28+
( cd "$PROJECT" && "$MVN" --batch-mode -q \
29+
"-Dmaven.ext.class.path=$JAR" \
30+
-Dcoana.task=socket-facts \
31+
-Dsocket.withFiles=true \
32+
"-Dmaven.repo.local=$M2" \
33+
"-Dstub.repo.url=file://$STUBS" \
34+
"-Dsocket.recordsFile=$RECORDS" \
35+
validate )
36+
37+
python3 - "$RECORDS" "$M2/demo/unsolvable" <<'PY'
38+
import os, sys
39+
rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()]
40+
module = 'demo:unsolvable-conflict:1.0'
41+
errors = []
42+
if not any(r[0] == 'unscannable' and r[3] == module for r in rows):
43+
errors.append(f"{module} must be reported unscannable; records={rows}")
44+
nodes = sorted(r[2] for r in rows if r[0] == 'node')
45+
if nodes:
46+
errors.append(f"Maven resolved no graph, so nothing may be emitted; nodes={nodes}")
47+
jars = sorted(f for _, _, fs in os.walk(sys.argv[2]) for f in fs if f.endswith('.jar'))
48+
if jars:
49+
errors.append(f"Maven resolved no graph, so nothing may be fetched; jars={jars}")
50+
if errors:
51+
print("FAIL:")
52+
for e in errors:
53+
print(" -", e)
54+
sys.exit(1)
55+
print(f"PASS (unsolvable conflict): {module} unscannable, nothing emitted or fetched")
56+
PY

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
# its transitive demo.ext:harness-core (only in a non-prod root -> the assembler's dev flag);
77
# - emits the internal reactor module demo:lib by its bare groupId:artifactId:version id (so the
88
# inter-module edge lines up with its `project` record);
9-
# - resolves a scope conflict so the retained node stays reachable from its parent root;
9+
# - marks a node prod by the scope Maven kept it with, and keeps it reachable in the prod root;
10+
# - records every resolved edge, pointing at the node Maven kept: onto a direct dependency, onto a
11+
# mediated version, and from a prod node onto a provided one;
1012
# - every component is reachable from a direct dependency of its own root (no orphaned components);
11-
# - keeps the dependencies Maven leaves only under a sibling module's test-jar (app reaches
12-
# demo.ext:libdep under lib's test-jar node, after lib's jar node through mid);
13+
# - keeps a dependency only a sibling module's test-jar brings (mid excludes demo.ext:libdep from
14+
# lib, and app reaches lib's jar through mid before lib's test-jar);
1315
# - materializes resolved external jars under -Dsocket.withFiles;
1416
# - scopes that materialization to -Dsocket.populateFilesFor (a newline-delimited GAV file).
1517
#
@@ -39,7 +41,14 @@ bash "$HERE/../make-stub-repo.sh" "$STUBS" \
3941
'demo.ext:conflict-test:1.0+demo.ext:shared:1.0' \
4042
'demo.ext:conflict-prod:1.0+demo.ext:shared:1.0' \
4143
'demo.ext:shared:1.0' \
42-
'demo.ext:libdep:1.0'
44+
'demo.ext:libdep:1.0' \
45+
'demo.ext:dup-util:1.0' \
46+
'demo.ext:dup-io:1.0+demo.ext:dup-util:1.0' \
47+
'demo.ext:med-util:1.0' \
48+
'demo.ext:med-util:2.0' \
49+
'demo.ext:med-io:1.0+demo.ext:med-util:2.0' \
50+
'demo.ext:container-api:1.0' \
51+
'demo.ext:container-lib:1.0+demo.ext:container-api:1.0'
4352
# Maven's own plugin closure stays cached between runs; the stubs never do, so every run has to
4453
# resolve them from the repo just generated.
4554
rm -rf "$M2/demo" "$RECORDS" "$PROJECT"/*/target "$PROJECT"/target
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0">
3+
<modelVersion>4.0.0</modelVersion>
4+
<groupId>demo</groupId>
5+
<artifactId>unsolvable-conflict</artifactId>
6+
<version>1.0</version>
7+
<properties>
8+
<stub.repo.url>file://${maven.multiModuleProjectDirectory}/localrepo</stub.repo.url>
9+
</properties>
10+
<repositories>
11+
<repository>
12+
<id>socket-stubs</id>
13+
<url>${stub.repo.url}</url>
14+
<releases>
15+
<enabled>true</enabled>
16+
<checksumPolicy>ignore</checksumPolicy>
17+
</releases>
18+
<snapshots>
19+
<enabled>false</enabled>
20+
</snapshots>
21+
</repository>
22+
</repositories>
23+
<!-- left and right pin demo.unsolvable:shared to [1.0] and [2.0], which no version satisfies. -->
24+
<dependencies>
25+
<dependency>
26+
<groupId>demo.unsolvable</groupId>
27+
<artifactId>left</artifactId>
28+
<version>1.0</version>
29+
</dependency>
30+
<dependency>
31+
<groupId>demo.unsolvable</groupId>
32+
<artifactId>right</artifactId>
33+
<version>1.0</version>
34+
</dependency>
35+
<dependency>
36+
<groupId>demo.unsolvable</groupId>
37+
<artifactId>harness</artifactId>
38+
<version>1.0</version>
39+
<scope>test</scope>
40+
</dependency>
41+
</dependencies>
42+
</project>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ run_maven() {
117117
bash "$HERE/maven-compat/smoke-test-duplicate-failure.sh" "$dir/bin/mvn" "$jar"
118118
bash "$HERE/maven-compat/smoke-test-graph-failure.sh" "$dir/bin/mvn" "$jar"
119119
bash "$HERE/maven-compat/smoke-test-descriptor-failure.sh" "$dir/bin/mvn" "$jar"
120+
bash "$HERE/maven-compat/smoke-test-unsolvable-conflict.sh" "$dir/bin/mvn" "$jar"
120121
done
121122
}
122123

0 commit comments

Comments
 (0)