From 96bad725d96bf27f250029c5618605ce62e7fbb4 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 12 Nov 2026 18:58:54 +0100 Subject: [PATCH 1/2] fix(manifest): keep Maven dependencies below a skipped scope With --exclude-configs or --include-configs, the Maven facts extension cut off the whole subtree of a dependency whose scope it skips. Maven filters each node by its own scope and still walks past the nodes it drops, and a dependencyManagement scope is not derived from the parent's. A dependency managed to compile below a test dependency is on Maven's runtime classpath and in a packaged war, but was missing from the facts. The extension now walks the whole resolved graph and drops skipped nodes afterwards, keeping the edges Maven resolved between kept nodes. A kept node reached only through skipped ones is linked to its nearest kept ancestors, and is direct when the module is one of them; no other node gets an edge Maven did not resolve. --- .gitignore | 3 + CHANGELOG.md | 1 + .../socket/SocketFactsRecordsEngine.java | 50 ++++++++++++- .../scripts/test/maven-compat/.gitignore | 2 + .../test/maven-compat/excluded-scope/pom.xml | 70 ++++++++++++++++++ .../maven-compat/smoke-test-excluded-scope.sh | 74 +++++++++++++++++++ .../manifest/scripts/test/run-compat.sh | 1 + 7 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 src/commands/manifest/scripts/test/maven-compat/excluded-scope/pom.xml create mode 100755 src/commands/manifest/scripts/test/maven-compat/smoke-test-excluded-scope.sh diff --git a/.gitignore b/.gitignore index 24b7ae1f1..79d17682a 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,7 @@ !/src/commands/manifest/scripts/test/maven-compat/exclude-base/app/ !/src/commands/manifest/scripts/test/maven-compat/exclude-base/app/core/ !/src/commands/manifest/scripts/test/maven-compat/exclude-base/shared/ +!/src/commands/manifest/scripts/test/maven-compat/excluded-scope/ !/src/commands/manifest/scripts/test/maven-compat/graph-failure/ !/src/commands/manifest/scripts/test/maven-compat/project/ !/src/commands/manifest/scripts/test/maven-compat/project/app/ @@ -760,6 +761,7 @@ !/src/commands/manifest/scripts/test/maven-compat/exclude-base/app/core/pom.xml !/src/commands/manifest/scripts/test/maven-compat/exclude-base/app/pom.xml !/src/commands/manifest/scripts/test/maven-compat/exclude-base/shared/pom.xml +!/src/commands/manifest/scripts/test/maven-compat/excluded-scope/pom.xml !/src/commands/manifest/scripts/test/maven-compat/graph-failure/pom.xml !/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml !/src/commands/manifest/scripts/test/maven-compat/project/app/src/main/java/demo/App.java @@ -774,6 +776,7 @@ !/src/commands/manifest/scripts/test/maven-compat/smoke-test-descriptor-failure.sh !/src/commands/manifest/scripts/test/maven-compat/smoke-test-duplicate-failure.sh !/src/commands/manifest/scripts/test/maven-compat/smoke-test-exclude-base.sh +!/src/commands/manifest/scripts/test/maven-compat/smoke-test-excluded-scope.sh !/src/commands/manifest/scripts/test/maven-compat/smoke-test-graph-failure.sh !/src/commands/manifest/scripts/test/maven-compat/smoke-test-repo-inheritance.sh !/src/commands/manifest/scripts/test/maven-compat/smoke-test-workspaces.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 079119e4b..4dda2db25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - Maven Socket facts honor artifact types that build extensions define, so they no longer list dependencies Maven doesn't resolve, or fetch files under the wrong extension. - Maven Socket facts no longer drop dependencies whose scope Maven 3 doesn't define, such as Maven 4's `compile-only` or `none`. +- Maven Socket facts with `--exclude-configs` or `--include-configs` keep dependencies Maven ships even when they sit below a skipped dependency. ## [1.15.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.15.0) - 2026-11-12 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 0587a61c0..8279b2af0 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 @@ -317,6 +317,7 @@ private void collectModule( String id = visit(child, edges, scopes, reactorGavs, unresolved, opts, nodes); if (id != null) directIds.add(id); } + dropSkipped(nodes, directIds); for (String id : unfetched) { Node node = nodes.get(id); if (node != null) node.unfetched = true; @@ -420,7 +421,6 @@ private String visit( Artifact artifact = dn.getArtifact(); if (dep == null || artifact == null) return null; String scope = scopeOf(dep); - if (!scopes.passes(scope)) return null; String gav = gav(artifact); if (unresolved.contains(gav)) return null; @@ -436,6 +436,7 @@ private String visit( // assembler merges them into its component. Node node = new Node(id, artifact.getGroupId(), artifact.getArtifactId(), type == null ? "" : type, classifier == null ? "" : classifier, artifact.getBaseVersion(), internal ? gav : ""); + node.skipped = !scopes.passes(scope); node.prod = scopes.prod(scope); node.onClasspath = scopes.onClasspath(scope); scopes.scanned(scope); @@ -455,6 +456,51 @@ private String visit( return id; } + // A node is kept by its own scope: Maven walks past the nodes its scope filter drops (maven-core + // RepositoryUtils.toArtifacts). A kept node reached only through dropped ones, as a managed scope + // allows (maven-resolver ConflictResolver.deriveScope; ClassicConflictResolver in 2.x), is linked to + // its nearest kept ancestors, the module making it direct. + private static void dropSkipped(Map nodes, Set directIds) { + if (nodes.values().stream().noneMatch(n -> n.skipped)) return; + Map> parents = new HashMap<>(); + for (Node n : nodes.values()) { + for (String child : n.children) parents.computeIfAbsent(child, k -> new HashSet<>()).add(n.id); + } + Set reached = new HashSet<>(); + Deque queue = new ArrayDeque<>(); + for (String id : directIds) { + if (!nodes.get(id).skipped && reached.add(id)) queue.add(id); + } + while (!queue.isEmpty()) { + for (String child : nodes.get(queue.poll()).children) { + if (!nodes.get(child).skipped && reached.add(child)) queue.add(child); + } + } + for (Node orphan : nodes.values()) { + if (orphan.skipped || reached.contains(orphan.id)) continue; + Set seen = new HashSet<>(); + Deque up = new ArrayDeque<>(); + for (String parent : parents.getOrDefault(orphan.id, Collections.emptySet())) { + if (nodes.get(parent).skipped && seen.add(parent)) up.add(parent); + } + while (!up.isEmpty()) { + String skipped = up.poll(); + if (directIds.contains(skipped)) directIds.add(orphan.id); + for (String parent : parents.getOrDefault(skipped, Collections.emptySet())) { + Node node = nodes.get(parent); + if (!node.skipped) { + if (!parent.equals(orphan.id)) node.children.add(orphan.id); + } else if (seen.add(parent)) { + up.add(parent); + } + } + } + } + directIds.removeIf(id -> nodes.get(id).skipped); + nodes.values().removeIf(n -> n.skipped); + for (Node n : nodes.values()) n.children.retainAll(nodes.keySet()); + } + private static String externalId(Artifact artifact) { // Maven's `type` rather than aether's file extension, so a test-jar keeps the coordId the // assembler and the Gradle/SBT scripts already emit. Base version: a resolved remote snapshot's @@ -1001,6 +1047,8 @@ private static final class Node { final String project; final TreeSet children = new TreeSet<>(); final TreeSet files = new TreeSet<>(); + // Its scope is filtered out by --include-configs/--exclude-configs. + boolean skipped; boolean prod; boolean onClasspath = true; boolean unfetched; diff --git a/src/commands/manifest/scripts/test/maven-compat/.gitignore b/src/commands/manifest/scripts/test/maven-compat/.gitignore index d37906a3c..cab5f54c9 100644 --- a/src/commands/manifest/scripts/test/maven-compat/.gitignore +++ b/src/commands/manifest/scripts/test/maven-compat/.gitignore @@ -26,3 +26,5 @@ unknown-scopes/localrepo/ unknown-scopes/records.tsv maven4-scopes/localrepo/ maven4-scopes/records.tsv +excluded-scope/localrepo/ +excluded-scope/records.tsv diff --git a/src/commands/manifest/scripts/test/maven-compat/excluded-scope/pom.xml b/src/commands/manifest/scripts/test/maven-compat/excluded-scope/pom.xml new file mode 100644 index 000000000..ffad117d5 --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/excluded-scope/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + demo + excluded-scope + 1.0 + + file://${maven.multiModuleProjectDirectory}/localrepo + + + + socket-stubs + ${stub.repo.url} + + true + ignore + + + false + + + + + + + + demo.excl + managed + 1.0 + compile + + + demo.excl + mgd-test + 1.0 + test + + + demo.excl + deep + 1.0 + compile + + + + + + demo.excl + testlib + 1.0 + test + + + demo.excl + lib + 1.0 + + + demo.excl + dtest + 1.0 + test + + + demo.excl + k2 + 1.0 + + + diff --git a/src/commands/manifest/scripts/test/maven-compat/smoke-test-excluded-scope.sh b/src/commands/manifest/scripts/test/maven-compat/smoke-test-excluded-scope.sh new file mode 100755 index 000000000..05cc57adc --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/smoke-test-excluded-scope.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Regression guard: with the test scope excluded, a node is kept by its own scope, as Maven keeps it, +# and only a node reached solely through excluded ones (managed, deep) is linked to its nearest kept +# ancestors; shared and e keep just the parents Maven resolved. +# +# Usage: smoke-test-excluded-scope.sh . +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +MVN="${1:?usage: smoke-test-excluded-scope.sh }" +JAR="${2:?usage: smoke-test-excluded-scope.sh }" +PROJECT="$HERE/excluded-scope" +RECORDS="$PROJECT/records.tsv" +# shellcheck source=SCRIPTDIR/../compat-cache.sh +. "$HERE/../compat-cache.sh" +M2="$SOCKET_COMPAT_CACHE/m2" + +bash "$HERE/../make-stub-repo.sh" "$PROJECT/localrepo" \ + 'demo.excl:testlib:1.0+demo.excl:managed:1.0,demo.excl:testdep:1.0,demo.excl:shared:1.0' \ + 'demo.excl:managed:1.0' \ + 'demo.excl:testdep:1.0' \ + 'demo.excl:lib:1.0+demo.excl:mgd-test:1.0,demo.excl:shared:1.0,demo.excl:dtest:1.0' \ + 'demo.excl:mgd-test:1.0+demo.excl:deep:1.0' \ + 'demo.excl:deep:1.0' \ + 'demo.excl:shared:1.0' \ + 'demo.excl:dtest:1.0+demo.excl:e:1.0' \ + 'demo.excl:k2:1.0+demo.excl:e:1.0' \ + 'demo.excl:e:1.0' >/dev/null +rm -rf "$M2/demo/excl" "$RECORDS" +echo "+ $("$MVN" -v 2>/dev/null | head -1) (excluded scope)" +( cd "$PROJECT" && "$MVN" --batch-mode -q \ + "-Dmaven.ext.class.path=$JAR" \ + -Dcoana.task=socket-facts \ + "-Dmaven.repo.local=$M2" \ + "-Dstub.repo.url=file://$PROJECT/localrepo" \ + -Dsocket.excludeConfigs=test \ + "-Dsocket.recordsFile=$RECORDS" \ + validate ) +python3 - "$RECORDS" <<'PY' +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +prod_roots = {r[1] for r in rows if r[0] == 'root' and r[4] == '1'} +name = lambda coord: coord.split(':')[1] +nodes = {} +for r in rows: + if r[0] == 'node': + nodes.setdefault(name(r[2]), []).append(r) +parents = {} +for r in rows: + if r[0] == 'edge': + parents.setdefault(name(r[3]), set()).add(name(r[2])) +errors = [] +for absent in ('testlib', 'testdep', 'mgd-test', 'dtest'): + if absent in nodes: + errors.append(f"{absent} is excluded but emitted") +for kept, direct in (('lib', True), ('k2', True), ('managed', True), ('deep', False), ('shared', False), ('e', False)): + rs = nodes.get(kept) + if not rs: + errors.append(f"{kept} missing") + continue + if not any(r[1] in prod_roots for r in rs): + errors.append(f"{kept} should be prod") + if any((r[8] == '1') != direct for r in rs): + errors.append(f"{kept} should {'' if direct else 'not '}be direct") +for child, expected in (('deep', {'lib'}), ('shared', {'lib'}), ('e', {'k2'})): + if parents.get(child, set()) != expected: + errors.append(f"{child} should have parents {sorted(expected)}, has {sorted(parents.get(child, set()))}") +if errors: + print("FAIL:") + for e in errors: + print(" -", e) + sys.exit(1) +print("PASS (excluded scope): managed direct, lib -> deep, shared and e keep their kept parents") +PY +assemble_records "$RECORDS" diff --git a/src/commands/manifest/scripts/test/run-compat.sh b/src/commands/manifest/scripts/test/run-compat.sh index 8bc6fb9e8..3975811ce 100755 --- a/src/commands/manifest/scripts/test/run-compat.sh +++ b/src/commands/manifest/scripts/test/run-compat.sh @@ -120,6 +120,7 @@ run_maven() { bash "$HERE/maven-compat/smoke-test-unsolvable-conflict.sh" "$dir/bin/mvn" "$jar" bash "$HERE/maven-compat/smoke-test-extension-type.sh" "$dir/bin/mvn" "$jar" bash "$HERE/maven-compat/smoke-test-scopes.sh" "$dir/bin/mvn" "$jar" + bash "$HERE/maven-compat/smoke-test-excluded-scope.sh" "$dir/bin/mvn" "$jar" done } From 553f4ba3fa6d5c71180cdaeb84999ba12976dfc6 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 12 Nov 2026 20:36:11 +0100 Subject: [PATCH 2/2] test(manifest): pin a range-capped Maven dependency below an excluded scope A range is a hard constraint whatever the scope declaring it, so an excluded test dependency's range still picks the version Maven ships. The kept dependency must keep its edge from the compile dependency whose own version lost the conflict, not become an orphan. --- .../maven-compat/smoke-test-excluded-scope.sh | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/commands/manifest/scripts/test/maven-compat/smoke-test-excluded-scope.sh b/src/commands/manifest/scripts/test/maven-compat/smoke-test-excluded-scope.sh index 05cc57adc..b90dc7338 100755 --- a/src/commands/manifest/scripts/test/maven-compat/smoke-test-excluded-scope.sh +++ b/src/commands/manifest/scripts/test/maven-compat/smoke-test-excluded-scope.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Regression guard: with the test scope excluded, a node is kept by its own scope, as Maven keeps it, # and only a node reached solely through excluded ones (managed, deep) is linked to its nearest kept -# ancestors; shared and e keep just the parents Maven resolved. +# ancestors; shared and e keep just the parents Maven resolved. ranged keeps its edge from k2, though +# the excluded testlib's range picks its version. # # Usage: smoke-test-excluded-scope.sh . set -euo pipefail @@ -15,7 +16,7 @@ RECORDS="$PROJECT/records.tsv" M2="$SOCKET_COMPAT_CACHE/m2" bash "$HERE/../make-stub-repo.sh" "$PROJECT/localrepo" \ - 'demo.excl:testlib:1.0+demo.excl:managed:1.0,demo.excl:testdep:1.0,demo.excl:shared:1.0' \ + 'demo.excl:testlib:1.0+demo.excl:managed:1.0,demo.excl:testdep:1.0,demo.excl:shared:1.0,demo.excl:ranged:RANGE' \ 'demo.excl:managed:1.0' \ 'demo.excl:testdep:1.0' \ 'demo.excl:lib:1.0+demo.excl:mgd-test:1.0,demo.excl:shared:1.0,demo.excl:dtest:1.0' \ @@ -23,8 +24,13 @@ bash "$HERE/../make-stub-repo.sh" "$PROJECT/localrepo" \ 'demo.excl:deep:1.0' \ 'demo.excl:shared:1.0' \ 'demo.excl:dtest:1.0+demo.excl:e:1.0' \ - 'demo.excl:k2:1.0+demo.excl:e:1.0' \ - 'demo.excl:e:1.0' >/dev/null + 'demo.excl:k2:1.0+demo.excl:e:1.0,demo.excl:ranged:2.0' \ + 'demo.excl:e:1.0' \ + 'demo.excl:ranged:1.5' \ + 'demo.excl:ranged:2.0' >/dev/null +# A range is a hard constraint whatever the scope declaring it (maven-resolver NearestVersionSelector; +# ConfigurableVersionSelector in 2.x), so Maven resolves ranged 1.5. +sed -i.bak 's|RANGE|[1.0,1.5]|' "$PROJECT/localrepo/demo/excl/testlib/1.0/testlib-1.0.pom" rm -rf "$M2/demo/excl" "$RECORDS" echo "+ $("$MVN" -v 2>/dev/null | head -1) (excluded scope)" ( cd "$PROJECT" && "$MVN" --batch-mode -q \ @@ -52,7 +58,7 @@ errors = [] for absent in ('testlib', 'testdep', 'mgd-test', 'dtest'): if absent in nodes: errors.append(f"{absent} is excluded but emitted") -for kept, direct in (('lib', True), ('k2', True), ('managed', True), ('deep', False), ('shared', False), ('e', False)): +for kept, direct in (('lib', True), ('k2', True), ('managed', True), ('deep', False), ('shared', False), ('e', False), ('ranged', False)): rs = nodes.get(kept) if not rs: errors.append(f"{kept} missing") @@ -61,7 +67,9 @@ for kept, direct in (('lib', True), ('k2', True), ('managed', True), ('deep', Fa errors.append(f"{kept} should be prod") if any((r[8] == '1') != direct for r in rs): errors.append(f"{kept} should {'' if direct else 'not '}be direct") -for child, expected in (('deep', {'lib'}), ('shared', {'lib'}), ('e', {'k2'})): +if any(r[5] != '1.5' for r in nodes.get('ranged', [])): + errors.append(f"ranged should resolve 1.5, got {sorted({r[5] for r in nodes['ranged']})}") +for child, expected in (('deep', {'lib'}), ('shared', {'lib'}), ('e', {'k2'}), ('ranged', {'k2'})): if parents.get(child, set()) != expected: errors.append(f"{child} should have parents {sorted(expected)}, has {sorted(parents.get(child, set()))}") if errors: @@ -69,6 +77,6 @@ if errors: for e in errors: print(" -", e) sys.exit(1) -print("PASS (excluded scope): managed direct, lib -> deep, shared and e keep their kept parents") +print("PASS (excluded scope): managed direct, lib -> deep, shared, e and ranged keep their kept parents") PY assemble_records "$RECORDS"