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..b90dc7338 --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/smoke-test-excluded-scope.sh @@ -0,0 +1,82 @@ +#!/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. ranged keeps its edge from k2, though +# the excluded testlib's range picks its version. +# +# 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: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' \ + '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: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 \ + "-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), ('ranged', 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") +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: + print("FAIL:") + for e in errors: + print(" -", e) + sys.exit(1) +print("PASS (excluded scope): managed direct, lib -> deep, shared, e and ranged 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 }