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 14e098e1e..bdf677ef7 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 @@ -53,7 +53,7 @@ * (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. + * where another depends on one of its artifacts, each of which names the module as its project. * *

Resolution goes through {@link ProjectDependenciesResolver} — the same component Maven's own * lifecycle uses to build a project's classpath — so the graph, the scope and management semantics, @@ -309,9 +309,8 @@ private void collectModule( reportUnscannable(unscannable, moduleCoord, "dependencies resolved without the socket-facts graph transformer"); } if (graph == null) return; - Set visited = new HashSet<>(); for (DependencyNode child : edges.get(graph)) { - String id = visit(child, edges, passingScopes, reactorGavs, unresolved, opts, nodes, visited); + String id = visit(child, edges, passingScopes, reactorGavs, unresolved, opts, nodes); if (id != null) directIds.add(id); } for (String id : unfetched) { @@ -410,8 +409,7 @@ private String visit( Set reactorGavs, Set unresolved, Options opts, - Map nodes, - Set visited) { + Map nodes) { Dependency dep = dn.getDependency(); Artifact artifact = dn.getArtifact(); if (dep == null || artifact == null) return null; @@ -423,20 +421,17 @@ private String visit( boolean internal = reactorGavs.contains(gav); String type = artifact.getProperty(ARTIFACT_PROPERTY_TYPE, artifact.getExtension()); String classifier = artifact.getClassifier(); - String version = artifact.getBaseVersion(); - String key = externalId(artifact); - String id = internal ? SocketSupport.bareId(artifact.getGroupId(), artifact.getArtifactId(), version) : key; + String id = externalId(artifact); - // 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 can collect different children, e.g. when one declaration has exclusions. - if (!visited.add(key)) return id; + // One walk per artifact: a shared subtree is already recorded. + if (nodes.containsKey(id)) 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; + // A sibling module's artifacts, such as its jar and test-jar, name the module as their project; the + // 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); + nodes.put(id, node); // 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. @@ -446,7 +441,7 @@ private String visit( } for (DependencyNode child : edges.get(dn)) { - String childId = visit(child, edges, passingScopes, reactorGavs, unresolved, opts, nodes, visited); + String childId = visit(child, edges, passingScopes, reactorGavs, unresolved, opts, nodes); if (childId != null) node.children.add(childId); } return id; @@ -476,17 +471,6 @@ private static boolean isProd(String scope) { return scope.equals("compile") || scope.equals("runtime") || scope.equals("system"); } - private static Node upsert( - Map nodes, String id, String groupId, String artifactId, String type, String classifier, String version, - String project) { - Node node = nodes.get(id); - if (node == null) { - node = new Node(id, groupId, artifactId, type, classifier, version, project); - nodes.put(id, node); - } - return node; - } - // ---- emission ---- // Shaped like Gradle's runtime and test classpaths. An edge from a prod node onto a dev one, such as diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketSupport.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketSupport.java index e14b981af..12d68a11c 100644 --- a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketSupport.java +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketSupport.java @@ -38,11 +38,6 @@ public static String coordId(String groupId, String artifactId, String type, Str return sb.toString(); } - /** Bare {@code groupId:artifactId:version} id used for reactor (internal) module components. */ - public static String bareId(String groupId, String artifactId, String version) { - return coordId(groupId, artifactId, null, null, version); - } - /** * Translate a config-name glob to a case-sensitive regex. Supports {@code *}, {@code ?}, and * {@code [...]} character classes: enumerations ({@code [cC]}), ranges ({@code [a-z]}), and diff --git a/src/commands/manifest/scripts/test/maven-compat/assert-records.py b/src/commands/manifest/scripts/test/maven-compat/assert-records.py index 66e8e374f..1275559c0 100755 --- a/src/commands/manifest/scripts/test/maven-compat/assert-records.py +++ b/src/commands/manifest/scripts/test/maven-compat/assert-records.py @@ -6,7 +6,7 @@ import sys rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] tool = None -roots, root_projects, nodes, files, direct, edges = {}, {}, {}, {}, {}, {} +roots, root_projects, nodes, projects, files, direct, edges = {}, {}, {}, {}, {}, {}, {} for r in rows: if r[0] == 'meta': tool = r[1] elif r[0] == 'root': @@ -16,6 +16,8 @@ elif r[0] == 'node': nodes.setdefault(r[2], set()).add(r[1]) # coordId -> {rootId} if r[8] == '1': direct.setdefault(r[2], set()).add(r[1]) # coordId -> {rootId where direct} + # coordId to the build projects its rows resolve it to. + projects.setdefault(r[2], set()).add(r[9] if len(r) > 9 else '') elif r[0] == 'file': files.setdefault(r[2], set()).add(r[3]) # coordId -> {path} elif r[0] == 'edge': # (parent, child) to the roots that hold the edge. @@ -27,17 +29,9 @@ # orphaned component. What remains here is Maven-specific: which coordinate lands in which root, # and jar materialization. -def coord(prefix, fields=None): - """The one emitted coordId starting with prefix, or None. Keeps assertions version-agnostic. - - fields pins the count of colon-separated fields. Without it a prefix that stops before the - type, as the bare-id lookup below does, would also match the typed form it is meant to rule - out: 'demo:lib:' is a prefix of both 'demo:lib:1.0' and 'demo:lib:jar:1.0'. - """ - hits = sorted( - c for c in set(nodes) | set(files) - if c.startswith(prefix) and (fields is None or c.count(':') + 1 == fields) - ) +def coord(prefix): + """The one emitted coordId starting with prefix, or None. Keeps assertions version-agnostic.""" + hits = sorted(c for c in set(nodes) | set(files) if c.startswith(prefix)) if len(hits) > 1: errors.append(f"expected one coordinate for {prefix!r}, got {hits}") return hits[0] if hits else None @@ -45,8 +39,8 @@ def coord(prefix, fields=None): ext = coord('demo.ext:tool:jar:') harness = coord('demo.ext:harness:jar:') harness_core = coord('demo.ext:harness-core:jar:') -# Internal module: bare groupId:artifactId:version, no type field. -lib = coord('demo:lib:', fields=3) +# Sibling module demo:lib: app reaches its jar through mid and declares its test-jar. +lib_jar, lib_tests = coord('demo:lib:jar:'), coord('demo:lib:test-jar:') if tool != 'maven': errors.append(f"meta tool {tool!r} != 'maven'") @@ -63,13 +57,23 @@ def has_jar(cid): return any(p.endswith('.jar') for p in files.get(cid, ())) if not harness_core: errors.append("missing transitive test dep demo.ext:harness-core") elif in_prod(harness_core): errors.append("transitive test dep demo.ext:harness-core wrongly in a prod root") -if not lib: errors.append("internal module demo:lib not emitted by its bare id") -elif not in_prod(lib): errors.append("internal module demo:lib not in app's prod root") -elif not direct.get(lib): errors.append("internal module demo:lib not marked direct") - def edge_roots(parent, child): return edges.get((parent, child), set()) +def direct_in(cid, project): return any(root_projects.get(rid) == project for rid in direct.get(cid, ())) + +if 'demo:lib:1.0' in nodes: errors.append("sibling demo:lib emitted under its bare id") +elif not lib_jar or not lib_tests: errors.append("sibling demo:lib's jar or test-jar missing") +elif projects.get(lib_jar, set()) | projects.get(lib_tests, set()) != {'demo:lib:1.0'}: + errors.append("sibling demo:lib's artifacts do not name demo:lib:1.0 as their project") +elif not any(roots.get(rid) and root_projects.get(rid) == 'demo:app:1.0' for rid in nodes.get(lib_jar, ())): + errors.append("sibling demo:lib's jar not in app's prod root") +elif not any(root_projects.get(rid) == 'demo:app:1.0' for rid in edge_roots(coord('demo:mid:jar:'), lib_jar)): + errors.append("edge demo:mid -> demo:lib's jar missing from app") +elif in_prod(lib_tests): errors.append("sibling demo:lib's test-jar wrongly in a prod root") +elif direct_in(lib_jar, 'demo:app:1.0'): errors.append("sibling demo:lib's jar wrongly direct in app") +elif not direct_in(lib_tests, 'demo:app:1.0'): errors.append("sibling demo:lib's test-jar not direct in app") + def edge_pair(parent, child): """Both coordinates, or None after reporting the missing ones.""" found = (coord(parent), coord(child)) @@ -119,4 +123,4 @@ def edge_pair(parent, child): print("FAIL:") for e in errors: print(" -", e) sys.exit(1) -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") +print(f"PASS: tool=maven; {ext} prod+jar; harness/harness-core dev; sibling demo:lib jar prod, test-jar direct; demo.ext:shared prod; edges onto kept dependencies") diff --git a/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml b/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml index 557f55716..5c54ecb76 100644 --- a/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml +++ b/src/commands/manifest/scripts/test/maven-compat/project/app/pom.xml @@ -20,9 +20,9 @@ - + libdep. --> demo mid diff --git a/src/commands/manifest/scripts/test/maven-compat/smoke-test-repo-inheritance.sh b/src/commands/manifest/scripts/test/maven-compat/smoke-test-repo-inheritance.sh index d3b9776a4..2ac369689 100755 --- a/src/commands/manifest/scripts/test/maven-compat/smoke-test-repo-inheritance.sh +++ b/src/commands/manifest/scripts/test/maven-compat/smoke-test-repo-inheritance.sh @@ -42,16 +42,22 @@ echo "+ $("$MVN" -v 2>/dev/null | head -1) (repo inheritance)" python3 - "$RECORDS" <<'PY' import sys rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] -roots, nodes, files, edges, failures = {}, {}, {}, set(), [] +roots, nodes, projects, files, edges, failures = {}, {}, {}, {}, set(), [] for r in rows: if r[0] == 'root': roots[r[1]] = r[2] # rootId -> projectKey - elif r[0] == 'node': nodes.setdefault(r[2], set()).add(r[1]) # coordId -> {rootId} + elif r[0] == 'node': + # coordId to the roots that hold it. + nodes.setdefault(r[2], set()).add(r[1]) + # coordId to the build project it resolves to, if any. + if len(r) > 9 and r[9]: projects[r[2]] = r[9] elif r[0] == 'file': files.setdefault(r[2], set()).add(r[3]) # coordId -> {path} elif r[0] == 'edge': edges.add((r[1], r[2], r[3])) # (rootId, parent, child) elif r[0] == 'failure': failures.append(r[1:]) WIDGET = 'demo.scoped:widget:jar:1.0' LIBA = 'demo:liba:1.0' +# liba's jar, which names liba as its project. +LIBA_JAR = 'demo:liba:jar:1.0' LIBB = 'demo:libb:1.0' errors = [] @@ -70,16 +76,19 @@ for project_key in (LIBA, LIBB): errors.append(f"{WIDGET} missing from {project_key}'s graph") # The point of the fixture: libb reaches widget only through the reactor module liba. -if not any(rid in roots_for(LIBB) and p == LIBA and c == WIDGET for rid, p, c in edges): - errors.append(f"no {LIBA} -> {WIDGET} edge in libb's root") +if not any(rid in roots_for(LIBB) and p == LIBA_JAR and c == WIDGET for rid, p, c in edges): + errors.append(f"no {LIBA_JAR} -> {WIDGET} edge in libb's root") jars = [p for p in files.get(WIDGET, ()) if p.endswith('.jar')] if not jars: errors.append(f"{WIDGET} jar not materialized: {files.get(WIDGET)}") # A reactor sibling reports its dirs through its `project` record, never a `file` record. -if files.get(LIBA): - errors.append(f"reactor module {LIBA} should have no file records, got {files[LIBA]}") +if projects.get(LIBA_JAR) != LIBA: + errors.append(f"{LIBA_JAR} does not name {LIBA} as its project") +liba_files = {p for c, ps in files.items() if c == LIBA_JAR or projects.get(c) == LIBA for p in ps} +if liba_files: + errors.append(f"reactor module {LIBA} should have no file records, got {sorted(liba_files)}") if errors: print("FAIL:") diff --git a/src/commands/manifest/scripts/test/maven-compat/smoke-test.sh b/src/commands/manifest/scripts/test/maven-compat/smoke-test.sh index 7628e07d3..f2560e094 100755 --- a/src/commands/manifest/scripts/test/maven-compat/smoke-test.sh +++ b/src/commands/manifest/scripts/test/maven-compat/smoke-test.sh @@ -4,15 +4,15 @@ # tested separately in `nx test utils`). Guards, across the supported Maven range, that the extension: # - emits the external prod dep demo.ext:tool (in a prod root) and the test dep demo.ext:harness + # its transitive demo.ext:harness-core (only in a non-prod root -> the assembler's dev flag); -# - emits the internal reactor module demo:lib by its bare groupId:artifactId:version id (so the -# inter-module edge lines up with its `project` record); +# - emits a sibling module's jar and test-jar under their own coordinates, each naming the module +# as its project (the assembler merges them into the module's component); # - marks a node prod by the scope Maven kept it with, also one whose scope dependencyManagement # sets under a test parent; # - records every resolved edge, pointing at the node Maven kept: onto a direct dependency, onto a # mediated version, and from a prod node onto a provided one; # - its records assemble into facts with no orphaned components; -# - keeps a dependency only a sibling module's test-jar brings (mid excludes demo.ext:libdep from -# lib, and app reaches lib's jar through mid before lib's test-jar); +# - keeps a dependency only a sibling module's test-jar brings under that test-jar, not its jar +# (mid excludes demo.ext:libdep from lib); # - materializes resolved external jars under -Dsocket.withFiles; # - scopes that materialization to -Dsocket.populateFilesFor (a newline-delimited GAV file). # @@ -71,9 +71,14 @@ python3 - "$RECORDS" <<'PY' import sys rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] app = {r[1] for r in rows if r[0] == 'root' and r[2] == 'demo:app:1.0'} -if not any(r[0] == 'node' and r[1] in app and r[2].startswith('demo.ext:libdep:') for r in rows): - print("FAIL: demo.ext:libdep missing from app"); sys.exit(1) -print("PASS: demo.ext:libdep in app through lib's test-jar") +def libdep_edge(parent): + return any(r[0] == 'edge' and r[1] in app and r[2].startswith(parent) + and r[3].startswith('demo.ext:libdep:') for r in rows) +if not libdep_edge('demo:lib:test-jar:'): + print("FAIL: no demo:lib test-jar -> demo.ext:libdep edge in app"); sys.exit(1) +if libdep_edge('demo:lib:jar:'): + print("FAIL: demo:lib jar -> demo.ext:libdep edge in app, though mid excludes it"); sys.exit(1) +print("PASS: demo.ext:libdep in app through lib's test-jar only") PY assemble_records "$RECORDS" diff --git a/test/maven-compat-smoke-assertions.test.mts b/test/maven-compat-smoke-assertions.test.mts index 6a956b025..dcbbbccb6 100644 --- a/test/maven-compat-smoke-assertions.test.mts +++ b/test/maven-compat-smoke-assertions.test.mts @@ -1,13 +1,10 @@ /** * Unit tests for the maven-compat fixture's record assertions. * - * The fixture proves the Maven extension emits internal reactor modules under - * their bare groupId:artifactId:version id, distinct from the typed - * groupId:artifactId:type:version form used for external artifacts. The lookup - * that checks this has to reject the typed form, or a regression that stopped - * emitting the bare id would still pass. The fixture also checks that the - * extension records every resolved edge and marks a node prod by the scope - * Maven kept it with. + * The fixture proves the Maven extension emits a sibling module's jar and + * test-jar under their own coordinates, each naming the module as its project, + * that it records every resolved edge, and that it marks a node prod by the + * scope Maven kept it with. * The fixture itself needs Maven and a JDK, so these run its assertions over * synthetic records instead. * @@ -38,11 +35,14 @@ function rootRow( return ['root', rootId, projectKey, '', options.prod ? '1' : '0'].join('\t') } -/** A `node` row: root id in field 2, coordinate in field 3, directness in field 9. */ +/** + * A `node` row: root id in field 2, coordinate in field 3, directness in field + * 9, the build project it resolves to in field 10. + */ function nodeRow( rootId: string, coordId: string, - options: { direct: boolean }, + options: { direct: boolean; project: string }, ): string { return [ 'node', @@ -54,6 +54,7 @@ function nodeRow( '', '', options.direct ? '1' : '0', + options.project, ].join('\t') } @@ -79,6 +80,15 @@ const MED_IO = 'demo.ext:med-io:jar:1.0' const MED_UTIL = 'demo.ext:med-util:jar:1.0' const CONTAINER_LIB = 'demo.ext:container-lib:jar:1.0' const CONTAINER_API = 'demo.ext:container-api:jar:1.0' +const MID_JAR = 'demo:mid:jar:1.0' +// The reactor module each sibling artifact's coordinate resolves to, by artifactId. +const SIBLING_PROJECTS: Record = { + __proto__: null, + lib: 'demo:lib:1.0', + mid: 'demo:mid:1.0', +} as unknown as Record +const LIB_JAR = 'demo:lib:jar:1.0' +const LIB_TESTS = 'demo:lib:test-jar:tests:1.0' const MGD_TEST = 'demo.ext:mgd-test:jar:1.0' const MANAGED = 'demo.ext:managed:jar:1.0' @@ -87,6 +97,7 @@ const DEV_NODES = new Set([ CONTAINER_API, HARNESS, HARNESS_CORE, + LIB_TESTS, MGD_TEST, ]) @@ -104,6 +115,7 @@ const EDGES = { harnessCore: { module: 'app', parent: HARNESS, child: HARNESS_CORE }, medIoUtil: { module: 'mid', parent: MED_IO, child: MED_UTIL }, mgdTestManaged: { module: 'app', parent: MGD_TEST, child: MANAGED }, + midLib: { module: 'app', parent: MID_JAR, child: LIB_JAR }, } satisfies Record function rootIdOf(module: string, prodRoot: boolean): string { @@ -112,24 +124,28 @@ function rootIdOf(module: string, prodRoot: boolean): string { type RecordsOptions = { dev?: string[] - internalModuleCoord?: string + extraNodes?: string[] + libProject?: string omitEdges?: Edge[] omitNodes?: string[] prod?: string[] + toggleDirect?: string[] } /** * Records in the extension's root layout: per module, a compile root with the * nodes Maven kept with a prod scope and a test root with the whole - * resolution. `dev`, `prod`, `omitEdges` and `omitNodes` alter it into outputs the - * assertions must reject. + * resolution. `dev`, `extraNodes`, `libProject`, `omitEdges`, `omitNodes`, + * `prod` and `toggleDirect` alter it into outputs the assertions must reject. */ function buildRecords({ dev = [], - internalModuleCoord = 'demo:lib:1.0', + extraNodes = [], + libProject = 'demo:lib:1.0', omitEdges = [], omitNodes = [], prod = [], + toggleDirect = [], }: RecordsOptions = {}): string { // Coordinate and directness of each module's nodes. const modules: Array<[string, Array<[string, boolean]>]> = [ @@ -148,7 +164,10 @@ function buildRecords({ [CONTAINER_LIB, true], [MGD_TEST, true], [MANAGED, false], - [internalModuleCoord, true], + [MID_JAR, true], + [LIB_JAR, false], + [LIB_TESTS, true], + ...extraNodes.map(coord => [coord, true] as [string, boolean]), ], ], [ @@ -156,6 +175,7 @@ function buildRecords({ [ [MED_UTIL, true], [MED_IO, true], + [LIB_JAR, true], ], ], ] @@ -170,7 +190,15 @@ function buildRecords({ rows.push(rootRow(rootId, `demo:${module}:1.0`, { prod: prodRoot })) for (const { 0: coord, 1: direct } of nodes) { if (inRoot(coord)) { - rows.push(nodeRow(rootId, coord, { direct })) + const project = coord.startsWith('demo:lib:') + ? libProject + : (SIBLING_PROJECTS[coord.split(':')[1] ?? ''] ?? '') + rows.push( + nodeRow(rootId, coord, { + direct: direct !== toggleDirect.includes(coord), + project, + }), + ) } } for (const edge of Object.values(EDGES)) { @@ -224,16 +252,50 @@ describe('maven-compat assert-records.py', () => { it.for([ { - // 'demo:lib:' is a prefix of 'demo:lib:jar:1.0', so a lookup that matched - // on the prefix alone would report bare-id coverage it does not have. - name: 'an internal module emitted only under a typed id', - options: { internalModuleCoord: 'demo:lib:jar:1.0' }, - message: 'internal module demo:lib not emitted by its bare id', + name: 'a sibling emitted under its bare id', + options: { extraNodes: ['demo:lib:1.0'] }, + message: 'sibling demo:lib emitted under its bare id', + }, + { + name: "a missing edge onto the sibling's jar", + options: { omitEdges: [EDGES.midLib] }, + message: "edge demo:mid -> demo:lib's jar missing from app", + }, + { + name: 'a missing sibling jar', + options: { omitNodes: [LIB_JAR] }, + message: "sibling demo:lib's jar or test-jar missing", + }, + { + name: 'a missing sibling test-jar', + options: { omitNodes: [LIB_TESTS] }, + message: "sibling demo:lib's jar or test-jar missing", + }, + { + name: 'sibling artifacts that do not name their project', + options: { libProject: '' }, + message: + "sibling demo:lib's artifacts do not name demo:lib:1.0 as their project", + }, + { + name: 'a sibling jar left dev', + options: { dev: [LIB_JAR] }, + message: "sibling demo:lib's jar not in app's prod root", + }, + { + name: 'a sibling test-jar in a prod root', + options: { prod: [LIB_TESTS] }, + message: "sibling demo:lib's test-jar wrongly in a prod root", + }, + { + name: 'a sibling jar marked direct', + options: { toggleDirect: [LIB_JAR] }, + message: "sibling demo:lib's jar wrongly direct in app", }, { - name: 'an internal module that is missing entirely', - options: { internalModuleCoord: 'demo:unrelated:1.0' }, - message: 'internal module demo:lib not emitted by its bare id', + name: 'a sibling test-jar not marked direct', + options: { toggleDirect: [LIB_TESTS] }, + message: "sibling demo:lib's test-jar not direct in app", }, { name: 'a compile-scoped node kept under a test parent left dev',