diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b080c6b..079119e4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,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`. ## [1.15.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.15.0) - 2026-11-12 diff --git a/src/commands/manifest/scripts/assemble.mts b/src/commands/manifest/scripts/assemble.mts index b2ca641c8..fae409ec9 100644 --- a/src/commands/manifest/scripts/assemble.mts +++ b/src/commands/manifest/scripts/assemble.mts @@ -55,12 +55,15 @@ type MergedNode = { type SubprojectGraph = { children: Map> + // The nodes of the roots on a classpath: reachability analyses only these. + classpath: Set direct: Set } type PerRoot = { projectKey: string prod: boolean + onClasspath: boolean target?: RawTarget | undefined nodes: Map } @@ -177,6 +180,7 @@ function buildPerRoot(parsed: ParsedRecords): Map { out.set(rootId, { projectKey: r.projectKey, prod: r.prod, + onClasspath: r.onClasspath, target: r.target, nodes, }) @@ -259,13 +263,16 @@ function buildSubprojectGraphs( perRoot: Map, ): Map { const graphs = new Map() - for (const { nodes, projectKey } of perRoot.values()) { + for (const { nodes, onClasspath, projectKey } of perRoot.values()) { let g = graphs.get(projectKey) if (!g) { - g = { children: new Map(), direct: new Set() } + g = { children: new Map(), classpath: new Set(), direct: new Set() } graphs.set(projectKey, g) } for (const [coordId, node] of nodes) { + if (onClasspath) { + g.classpath.add(coordId) + } let set = g.children.get(coordId) if (!set) { set = new Set() @@ -480,8 +487,10 @@ function buildArtifactPaths( } return { pathsById, - classpathByProject: byProject(projects, graphs, g => g.children.keys()), - directDependenciesByProject: byProject(projects, graphs, g => g.direct), + classpathByProject: byProject(projects, graphs, g => g.classpath), + directDependenciesByProject: byProject(projects, graphs, g => + [...g.direct].filter(id => g.classpath.has(id)), + ), } } diff --git a/src/commands/manifest/scripts/assemble.test.mts b/src/commands/manifest/scripts/assemble.test.mts index 62c6ad3c8..3c7bdf498 100644 --- a/src/commands/manifest/scripts/assemble.test.mts +++ b/src/commands/manifest/scripts/assemble.test.mts @@ -441,6 +441,47 @@ describe('records → assemble → sidecar', () => { expect(assemble).not.toThrow() } }) + it('keeps the nodes of a root on no classpath in the SBOM but off every classpath', () => { + // Maven 4: `none` is declared, and resolved with its subtree, but on no + // path. Its child x is also on the classpath through lib. + const records = [ + 'meta\tmaven\t4.0.0\t21', + 'project\ta\tg\ta\t1\ta', + 'root\tr1\ta\tcompile\t1', + 'node\tr1\tg:lib:jar:1\tg\tlib\t1\tjar\t\t1', + 'node\tr1\tg:x:jar:1\tg\tx\t1\tjar\t\t0', + 'edge\tr1\tg:lib:jar:1\tg:x:jar:1', + 'root\tr2\ta\tnone\t0', + 'noClasspath\tr2', + 'node\tr2\tg:lib:jar:1\tg\tlib\t1\tjar\t\t1', + 'node\tr2\tg:x:jar:1\tg\tx\t1\tjar\t\t0', + 'node\tr2\tg:none:jar:1\tg\tnone\t1\tjar\t\t1', + 'node\tr2\tg:none-dep:jar:1\tg\tnone-dep\t1\tjar\t\t0', + 'edge\tr2\tg:lib:jar:1\tg:x:jar:1', + 'edge\tr2\tg:none:jar:1\tg:x:jar:1', + 'edge\tr2\tg:none:jar:1\tg:none-dep:jar:1', + ].join('\n') + + const { artifactPaths, facts } = assembleFacts(parseRecords(records)) + + expect(trees(facts)).toEqual({ + a: [ + 'g:lib:jar:1(g:x:jar:1)', + 'g:none:jar:1(g:x:jar:1, g:none-dep:jar:1)', + ], + }) + expect(facts.components.filter(c => c.dev).map(c => c.id)).toEqual([ + 'g:none-dep:jar:1', + 'g:none:jar:1', + ]) + expect(artifactPaths.classpathByProject.get('a')).toEqual([ + 'g:lib:jar:1', + 'g:x:jar:1', + ]) + expect(artifactPaths.directDependenciesByProject.get('a')).toEqual([ + 'g:lib:jar:1', + ]) + }) it("gives a dependency on a build project that project's id, and marks only it firstParty", () => { const records = [ 'meta\tgradle\t8.0\t17', diff --git a/src/commands/manifest/scripts/facts.mts b/src/commands/manifest/scripts/facts.mts index e3fb6831a..06c09a06e 100644 --- a/src/commands/manifest/scripts/facts.mts +++ b/src/commands/manifest/scripts/facts.mts @@ -101,9 +101,11 @@ export type ComponentFramework = { export type ResolvedArtifactPaths = { pathsById: Map // Component ids on each project's resolved classpath (union over its - // configurations), keyed by project id. + // configurations), keyed by project id. A component on no classpath, such as + // a Maven 4 `none` dependency, is in the SBOM but not here. classpathByProject: Map - // Component ids of each project's direct dependencies, keyed by project id. + // Component ids of each project's direct dependencies on its classpath, keyed + // by project id. directDependenciesByProject: Map // dotnet only: the same per target framework. frameworksByProject?: Map | undefined 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 35222d7c3..0587a61c0 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 @@ -76,9 +76,6 @@ public static final class Options { public String recordsFile; } - private static final List ALL_SCOPES = - Arrays.asList("compile", "provided", "runtime", "system", "test"); - // Aether's ArtifactProperties.TYPE: Maven's `type` (jar, test-jar, ...) as opposed to the file // extension, carried as an artifact property once a Maven dependency becomes an Aether one. private static final String ARTIFACT_PROPERTY_TYPE = "type"; @@ -116,7 +113,7 @@ public SocketFactsRecordsEngine( public void run(MavenSession session, List reactor, File rootDir, Options opts) throws IOException { - Set passingScopes = computePassingScopes(opts.includeConfigs, opts.excludeConfigs); + Scopes scopes = new Scopes(!maven3ScopeRules, opts.includeConfigs, opts.excludeConfigs); // GAVs to materialize under --with-files (null = all). Scopes artifact downloads so reachability // doesn't fetch the whole dependency universe. Module src/tgt dirs are emitted regardless (no download). Set populateGavs = readPopulateGavs(opts); @@ -157,8 +154,6 @@ public void run(MavenSession session, List reactor, File rootDir, } } - for (String scope : passingScopes) rec(lines, "scanned", scope); - Set failures = new LinkedHashSet<>(); Set unscannable = new LinkedHashSet<>(); int rootIdx = 0; @@ -171,13 +166,14 @@ public void run(MavenSession session, List reactor, File rootDir, ClassLoader previous = Thread.currentThread().getContextClassLoader(); if (module.getClassRealm() != null) Thread.currentThread().setContextClassLoader(module.getClassRealm()); try { - collectModule(session, module, passingScopes, reactorGavs, populateGavs, opts, nodes, directIds, failures, unscannable); + collectModule(session, module, scopes, reactorGavs, populateGavs, opts, nodes, directIds, failures, unscannable); } finally { Thread.currentThread().setContextClassLoader(previous); } rootIdx = emitModuleRoots(lines, rootIdx, projectKey(module), nodes, directIds); } + for (String scope : scopes.scanned()) rec(lines, "scanned", scope); for (Failure f : failures) rec(lines, "failure", f.coord, f.detail, f.config, f.project); for (Unscannable u : unscannable) rec(lines, "unscannable", "", u.detail, u.project); @@ -197,7 +193,7 @@ private static String projectKey(MavenProject module) { private void collectModule( MavenSession session, MavenProject module, - Set passingScopes, + Scopes scopes, Set reactorGavs, Set populateGavs, Options opts, @@ -208,7 +204,7 @@ private void collectModule( String moduleCoord = module.getGroupId() + ":" + module.getArtifactId() + ":" + module.getVersion(); DependencyResolutionResult result; DependencyResolutionException thrown = null; - DependencyFilter resolutionFilter = materializationFilter(opts, passingScopes, reactorGavs, populateGavs); + DependencyFilter resolutionFilter = materializationFilter(opts, scopes, reactorGavs, populateGavs); observer.reset(); EdgeRecorder recorder = new EdgeRecorder(session.getRepositorySession().getDependencyGraphTransformer()); DefaultRepositorySystemSession recording = new DefaultRepositorySystemSession(session.getRepositorySession()); @@ -256,16 +252,16 @@ private void collectModule( ? Collections.>emptyMap() : scopesByGav(graph); for (Map.Entry e : brokenDescriptors.entrySet()) { - Set scopes = scopesByGav.get(e.getKey()); - if (scopes == null) continue; + Set gavScopes = scopesByGav.get(e.getKey()); + if (gavScopes == null) continue; unresolved.add(e.getKey()); - reported |= addFailures(failures, e.getKey(), e.getValue().detail, scopes, passingScopes, moduleCoord); + reported |= addFailures(failures, e.getKey(), e.getValue().detail, gavScopes, scopes, moduleCoord); } for (Map.Entry e : incompleteDescriptors.entrySet()) { // A strict descriptor policy also throws for it: already reported above. if (brokenDescriptors.containsKey(e.getKey())) continue; - Set scopes = scopesByGav.get(e.getKey()); - if (scopes != null) reported |= addFailures(failures, e.getKey(), e.getValue(), scopes, passingScopes, moduleCoord); + Set gavScopes = scopesByGav.get(e.getKey()); + if (gavScopes != null) reported |= addFailures(failures, e.getKey(), e.getValue(), gavScopes, scopes, moduleCoord); } Set placed = new HashSet<>(); Set onClasspath = graph == null ? Collections.emptySet() : edges.keySet(); @@ -276,7 +272,7 @@ private void collectModule( placed.add(coord); if (scope == null) { log.debug("[socket-facts] " + coord + " failed but sits on no classpath of " + moduleCoord); - } else if (scope.isEmpty() || passingScopes.contains(scope)) { + } else if (scope.isEmpty() || scopes.passes(scope)) { failures.add(new Failure(coord, failed.detail, scope, moduleCoord)); } } @@ -288,7 +284,7 @@ private void collectModule( if (scopesByGav.containsKey(e.getKey()) || placed.contains(e.getKey())) continue; if (isDirectChild(graph, e.getValue().artifact)) continue; String scope = declaredScope(session, module, e.getValue().artifact); - if (scope != null && passingScopes.contains(scope)) { + if (scope != null && scopes.passes(scope)) { failures.add(new Failure(e.getKey(), e.getValue().detail, scope, moduleCoord)); reported = true; } @@ -318,7 +314,7 @@ private void collectModule( } if (graph == null) return; for (DependencyNode child : edges.get(graph)) { - String id = visit(child, edges, passingScopes, reactorGavs, unresolved, opts, nodes); + String id = visit(child, edges, scopes, reactorGavs, unresolved, opts, nodes); if (id != null) directIds.add(id); } for (String id : unfetched) { @@ -339,7 +335,7 @@ private void collectModule( */ private static DependencyFilter materializationFilter( final Options opts, - final Set passingScopes, + final Scopes scopes, final Set reactorGavs, final Set populateGavs) { if (!opts.withFiles) { @@ -356,7 +352,9 @@ public boolean accept(DependencyNode node, List parents) { Dependency dep = node == null ? null : node.getDependency(); Artifact artifact = dep == null ? null : dep.getArtifact(); if (artifact == null) return false; - if (!passingScopes.contains(scopeOf(dep))) return false; + // A dependency on no path is never fetched. + String scope = scopeOf(dep); + if (!scopes.passes(scope) || !scopes.onClasspath(scope)) return false; String gav = gav(artifact); if (reactorGavs.contains(gav)) return false; return populateGavs == null || populateGavs.contains(gav); @@ -413,7 +411,7 @@ private void reportUnfetched( private String visit( DependencyNode dn, Map> edges, - Set passingScopes, + Scopes scopes, Set reactorGavs, Set unresolved, Options opts, @@ -422,7 +420,7 @@ private String visit( Artifact artifact = dn.getArtifact(); if (dep == null || artifact == null) return null; String scope = scopeOf(dep); - if (!passingScopes.contains(scope)) return null; + if (!scopes.passes(scope)) return null; String gav = gav(artifact); if (unresolved.contains(gav)) return null; @@ -438,7 +436,9 @@ 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.prod = isProd(scope); + node.prod = scopes.prod(scope); + node.onClasspath = scopes.onClasspath(scope); + scopes.scanned(scope); nodes.put(id, node); // Maven wrote each accepted node's resolved file back onto the node; a reactor module reports its @@ -449,7 +449,7 @@ private String visit( } for (DependencyNode child : edges.get(dn)) { - String childId = visit(child, edges, passingScopes, reactorGavs, unresolved, opts, nodes); + String childId = visit(child, edges, scopes, reactorGavs, unresolved, opts, nodes); if (childId != null) node.children.add(childId); } return id; @@ -475,31 +475,39 @@ private static String scopeOf(Dependency dep) { return scope == null || scope.isEmpty() ? "compile" : scope; } - private static boolean isProd(String scope) { - return scope.equals("compile") || scope.equals("runtime") || scope.equals("system"); - } - // ---- emission ---- // 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. + // onto a dependency the module declares `provided`, lives in the test root only. A dependency on no + // path lives only in a root off every classpath, which holds the whole resolution. private int emitModuleRoots( List lines, int rootIdx, String projectKey, Map nodes, Set directIds) { Map prod = new LinkedHashMap<>(); - for (Node n : nodes.values()) if (n.prod) prod.put(n.id, n); + Map classpath = new LinkedHashMap<>(); + Map noPath = new LinkedHashMap<>(); + for (Node n : nodes.values()) { + if (n.prod && n.onClasspath) prod.put(n.id, n); + if (n.onClasspath) classpath.put(n.id, n); + else noPath.put(n.id, n); + } if (!prod.isEmpty()) { - rootIdx = emitRoot(lines, rootIdx, projectKey, "compile", true, prod, directIds); + rootIdx = emitRoot(lines, rootIdx, projectKey, "compile", true, true, prod, directIds); } - if (!nodes.isEmpty()) { - rootIdx = emitRoot(lines, rootIdx, projectKey, "test", false, nodes, directIds); + if (!classpath.isEmpty()) { + rootIdx = emitRoot(lines, rootIdx, projectKey, "test", false, true, classpath, directIds); + } + if (!noPath.isEmpty()) { + rootIdx = emitRoot(lines, rootIdx, projectKey, "none", false, false, nodes, directIds); } return rootIdx; } private int emitRoot( - List lines, int rootIdx, String projectKey, String config, boolean prod, Map nodeMap, Set directIds) { + List lines, int rootIdx, String projectKey, String config, boolean prod, boolean onClasspath, + Map nodeMap, Set directIds) { String rootId = Integer.toString(rootIdx); rec(lines, "root", rootId, projectKey, config, prod ? "1" : "0"); + if (!onClasspath) rec(lines, "noClasspath", rootId); for (Node n : nodeMap.values()) { rec(lines, "node", rootId, n.id, n.groupId, n.artifactId, n.version, n.type, n.classifier, directIds.contains(n.id) ? "1" : "0", n.project); @@ -514,16 +522,54 @@ private int emitRoot( // ---- scopes / module files ---- - private Set computePassingScopes(String includeConfigs, String excludeConfigs) { - List includes = SocketSupport.parsePatterns(includeConfigs); - List excludes = SocketSupport.parsePatterns(excludeConfigs); - Set passing = new TreeSet<>(); - for (String scope : ALL_SCOPES) { - if (matchesAny(excludes, scope)) continue; - if (!includes.isEmpty() && !matchesAny(includes, scope)) continue; - passing.add(scope); + /** + * How Maven treats a dependency scope, and which scopes --include-configs/--exclude-configs pass. + * Maven 3 knows five scopes and lets any other through every classpath filter, so an unknown scope + * (`import`, or a typo, in {@code }) is on every classpath. Maven 4 adds four, which + * maven-api-core's DependencyScope and PathScope define; a scope it does not define is treated as + * Maven 3 treats it, as Maven 4's default plugins do. + */ + private static final class Scopes { + private static final List MAVEN3 = Arrays.asList("compile", "provided", "runtime", "system", "test"); + private static final List MAVEN4 = Arrays.asList("compile-only", "none", "test-only", "test-runtime"); + private final boolean maven4; + private final List includes; + private final List excludes; + private final Map passing = new HashMap<>(); + private final Set scanned = new TreeSet<>(); + + Scopes(boolean maven4, String includeConfigs, String excludeConfigs) { + this.maven4 = maven4; + this.includes = SocketSupport.parsePatterns(includeConfigs); + this.excludes = SocketSupport.parsePatterns(excludeConfigs); + for (String scope : MAVEN3) scanned(scope); + if (maven4) for (String scope : MAVEN4) scanned(scope); + } + + boolean passes(String scope) { + return passing.computeIfAbsent( + scope, s -> !matchesAny(excludes, s) && (includes.isEmpty() || matchesAny(includes, s))); + } + + // Prod: Maven 4's main runtime path (compile, runtime), system, and any scope Maven 3 does not know. + boolean prod(String scope) { + if (scope.equals("provided") || scope.equals("test")) return false; + return !(maven4 && MAVEN4.contains(scope)); + } + + // Maven 4's none is in no path scope. + boolean onClasspath(String scope) { + return !(maven4 && scope.equals("none")); + } + + /** Records a scope as scanned if it passes: one Maven defines, or one a resolved graph used. */ + void scanned(String scope) { + if (passes(scope)) scanned.add(scope); + } + + Set scanned() { + return scanned; } - return passing; } private static boolean matchesAny(List patterns, String name) { @@ -671,11 +717,11 @@ private static FailedRequest failedRequest(Exception e) { } private static boolean addFailures( - Set failures, String coord, String detail, Set scopes, Set passingScopes, + Set failures, String coord, String detail, Set gavScopes, Scopes scopes, String moduleCoord) { boolean added = false; - for (String scope : scopes) { - if (passingScopes.contains(scope)) added |= failures.add(new Failure(coord, detail, scope, moduleCoord)); + for (String scope : gavScopes) { + if (scopes.passes(scope)) added |= failures.add(new Failure(coord, detail, scope, moduleCoord)); } return added; } @@ -956,6 +1002,7 @@ private static final class Node { final TreeSet children = new TreeSet<>(); final TreeSet files = new TreeSet<>(); boolean prod; + boolean onClasspath = true; boolean unfetched; Node(String id, String groupId, String artifactId, String type, String classifier, String version, String project) { diff --git a/src/commands/manifest/scripts/records.mts b/src/commands/manifest/scripts/records.mts index 48cb3b6a7..6b60106e5 100644 --- a/src/commands/manifest/scripts/records.mts +++ b/src/commands/manifest/scripts/records.mts @@ -18,6 +18,7 @@ import type { ResolutionFailure, Unscannable } from './resolution-report.mts' // projectBuild projectKey path (build-root-relative; may be absent on disk) // excludedProject projectKey (--with-files only; paths without a project) // root rootId projectKey config prod(0|1) [framework runtime alias] (dotnet: NuGet short folder name, RID, TargetFrameworks entry) +// noClasspath rootId (its nodes are on no classpath: in the SBOM, not on a reachability classpath) // node rootId coordId group name version ext classifier direct(0|1) project // edge rootId parentCoordId childCoordId // file rootId coordId path (--with-files only) @@ -65,6 +66,7 @@ export type RawRoot = { rootId: string projectKey: string prod: boolean + onClasspath: boolean target?: RawTarget | undefined nodes: Map edges: Array<[string, string]> @@ -191,6 +193,7 @@ export function parseRecords(text: string): ParsedRecords { rootId: id, projectKey: '', prod: false, + onClasspath: true, nodes: new Map(), edges: [], } @@ -347,6 +350,10 @@ export function parseRecords(text: string): ParsedRecords { } break } + case 'noClasspath': { + root(f[1] ?? '').onClasspath = false + break + } case 'unfetched': { const node = root(f[1] ?? '').nodes.get(f[2] ?? '') if (node) { diff --git a/src/commands/manifest/scripts/test/maven-compat/.gitignore b/src/commands/manifest/scripts/test/maven-compat/.gitignore index 8c20122e9..d37906a3c 100644 --- a/src/commands/manifest/scripts/test/maven-compat/.gitignore +++ b/src/commands/manifest/scripts/test/maven-compat/.gitignore @@ -22,3 +22,7 @@ unsolvable-conflict/localrepo/ extension-type/localrepo/ extension-type/records.tsv extension-type/target/ +unknown-scopes/localrepo/ +unknown-scopes/records.tsv +maven4-scopes/localrepo/ +maven4-scopes/records.tsv diff --git a/src/commands/manifest/scripts/test/maven-compat/maven4-scopes/pom.xml b/src/commands/manifest/scripts/test/maven-compat/maven4-scopes/pom.xml new file mode 100644 index 000000000..541d14d25 --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/maven4-scopes/pom.xml @@ -0,0 +1,55 @@ + + + 4.1.0 + demo + maven4-scopes + 1.0 + + file://${maven.multiModuleProjectDirectory}/localrepo + + + + socket-stubs + ${stub.repo.url} + + true + ignore + + + false + + + + + + + demo.scopes + compile-only + 1.0 + compile-only + + + demo.scopes + test-only + 1.0 + test-only + + + demo.scopes + test-runtime + 1.0 + test-runtime + + + demo.scopes + lib + 1.0 + + + demo.scopes + none + 1.0 + none + + + diff --git a/src/commands/manifest/scripts/test/maven-compat/smoke-test-scopes.sh b/src/commands/manifest/scripts/test/maven-compat/smoke-test-scopes.sh new file mode 100755 index 000000000..8df7881ac --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/smoke-test-scopes.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Regression guard: a scope outside Maven 3's five must not drop the dependency or its subtree. +# Maven 3 puts the jar of a scope it does not know (`import`, or a typo, in ) on every +# classpath, so it is prod. Maven 4's compile-only, test-only and test-runtime are dev, and its none +# is on no path: in the SBOM, but only in roots marked noClasspath. +# +# Usage: smoke-test-scopes.sh . +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +MVN="${1:?usage: smoke-test-scopes.sh }" +JAR="${2:?usage: smoke-test-scopes.sh }" +# shellcheck source=SCRIPTDIR/../compat-cache.sh +. "$HERE/../compat-cache.sh" +M2="$SOCKET_COMPAT_CACHE/m2" +VERSION="$("$MVN" -v 2>/dev/null | head -1)" + +# $1: fixture; then one `artifact:prod:onClasspath[:extra]` expectation per direct dependency, each +# of which has a child `-dep` expected alike, and, with `extra`, also that child. +check_fixture() { + local project="$HERE/$1" records="$HERE/$1/records.tsv" specs=() expectation artifact + shift + for expectation in "$@"; do + IFS=: read -r artifact _ _ extra <<<"$expectation" + specs+=("demo.scopes:$artifact:1.0+demo.scopes:$artifact-dep:1.0${extra:+,demo.scopes:$extra:1.0}") + specs+=("demo.scopes:$artifact-dep:1.0") + done + bash "$HERE/../make-stub-repo.sh" "$project/localrepo" "${specs[@]}" >/dev/null + rm -rf "$M2/demo/scopes" "$records" + ( 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.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'} +off_classpath = {r[1] for r in rows if r[0] == 'noClasspath'} +roots_of = {} +for r in rows: + if r[0] == 'node': + roots_of.setdefault(r[2].split(':')[1], set()).add(r[1]) +edges = {(r[2].split(':')[1], r[3].split(':')[1]) for r in rows if r[0] == 'edge'} +errors = [] +for expectation in sys.argv[2:]: + artifact, prod, on_classpath, *extra = expectation.split(':') + for child in [f'{artifact}-dep', *extra]: + if (artifact, child) not in edges: + errors.append(f"edge {artifact} -> {child} missing") + for name in (artifact, f'{artifact}-dep'): + roots = roots_of.get(name) + if not roots: + errors.append(f"{name} missing") + elif bool(roots & prod_roots) != (prod == 'prod'): + errors.append(f"{name} should be {prod}") + elif bool(roots - off_classpath) != (on_classpath == 'classpath'): + errors.append(f"{name} should be on {on_classpath}") +if errors: + print("FAIL:") + for e in errors: + print(" -", e) + sys.exit(1) +print(f"PASS (scopes): {', '.join(sys.argv[2:])}") +PY +} + +echo "+ $VERSION (scopes)" +check_fixture unknown-scopes imported:prod:classpath mistyped:prod:classpath +case "$VERSION" in + *"Maven 4."*) + # none also depends on lib-dep, which lib keeps on the classpath. + check_fixture maven4-scopes \ + compile-only:dev:classpath test-only:dev:classpath test-runtime:dev:classpath \ + none:dev:no-classpath:lib-dep lib:prod:classpath ;; +esac diff --git a/src/commands/manifest/scripts/test/maven-compat/unknown-scopes/pom.xml b/src/commands/manifest/scripts/test/maven-compat/unknown-scopes/pom.xml new file mode 100644 index 000000000..73106502e --- /dev/null +++ b/src/commands/manifest/scripts/test/maven-compat/unknown-scopes/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + demo + unknown-scopes + 1.0 + + file://${maven.multiModuleProjectDirectory}/localrepo + + + + socket-stubs + ${stub.repo.url} + + true + ignore + + + false + + + + + + + demo.scopes + imported + 1.0 + import + + + demo.scopes + mistyped + 1.0 + optional + + + diff --git a/src/commands/manifest/scripts/test/run-compat.sh b/src/commands/manifest/scripts/test/run-compat.sh index e21ded508..8bc6fb9e8 100755 --- a/src/commands/manifest/scripts/test/run-compat.sh +++ b/src/commands/manifest/scripts/test/run-compat.sh @@ -119,6 +119,7 @@ run_maven() { bash "$HERE/maven-compat/smoke-test-descriptor-failure.sh" "$dir/bin/mvn" "$jar" 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" done }