From c4a6824140f40176af94e36fc57a39648634c4d1 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Thu, 12 Nov 2026 12:54:18 +0100 Subject: [PATCH 1/2] test(manifest): check Maven reachability per project, as the assembler does A prod node whose scope dependencyManagement sets sits in Maven's compile root without a parent there; its edge lives in the test root. The assembler merges a project's roots, so the facts are fine, but the shared reachability assertion rejected the Maven records. Maven's smoke test now checks each project with --per-project, which merges a project's roots the way the assembler does. Gradle and sbt keep the stricter per-root check, as their roots are independent resolutions. Both modes now drop an edge whose ends are not both nodes of its root, as the assembler does, so a dangling edge no longer makes a node look reachable. The Maven fixture covers the managed-scope case. --- src/commands/manifest/scripts/test/README.md | 4 +- .../scripts/test/assert-reachability.py | 62 ++++++---- .../test/maven-compat/assert-records.py | 10 +- .../test/maven-compat/project/app/pom.xml | 18 +++ .../scripts/test/maven-compat/smoke-test.sh | 15 ++- .../manifest-reachability-assertions.test.mts | 117 ++++++++++++++++-- test/maven-compat-smoke-assertions.test.mts | 28 ++++- 7 files changed, 212 insertions(+), 42 deletions(-) diff --git a/src/commands/manifest/scripts/test/README.md b/src/commands/manifest/scripts/test/README.md index 4a07aafe2a..fa1cb0f26b 100644 --- a/src/commands/manifest/scripts/test/README.md +++ b/src/commands/manifest/scripts/test/README.md @@ -11,7 +11,9 @@ expected line-protocol records. Every ecosystem's records are also checked against the shared `assert-reachability.py` invariant: within each resolution root, every component must be reachable from one of that root's direct dependencies, or the manifest -consumer reports it as an orphaned component. It's unit-tested over +consumer reports it as an orphaned component. Maven checks each project instead, +its roots merged as the assembler merges them, since its compile root holds +nodes whose parent edge only its test root can hold. It's unit-tested over synthetic records in `test/manifest-reachability-assertions.test.mts`. ## Run locally, on demand diff --git a/src/commands/manifest/scripts/test/assert-reachability.py b/src/commands/manifest/scripts/test/assert-reachability.py index a5972e4a91..31d611e008 100755 --- a/src/commands/manifest/scripts/test/assert-reachability.py +++ b/src/commands/manifest/scripts/test/assert-reachability.py @@ -1,47 +1,63 @@ #!/usr/bin/env python3 # Assert the manifest REACHABILITY invariant over a records file emitted by any of the JVM -# build-tool scripts: within each resolution root, every component must be reachable from one of -# that root's direct dependencies by following `edge` rows. +# build-tool scripts: every component must be reachable from a direct dependency by following +# `edge` rows, an edge kept only where both ends are nodes of its own root, as the assembler keeps +# it. By default each resolution root is checked on its own. With --per-project, a project's roots +# are merged as the assembler merges them, a node keyed by the build project it resolves to, if +# any. Maven needs that, as its compile root holds a node whose parent edge only its test root holds. # # The manifest consumer reports a component that is neither direct nor referenced as an "orphaned -# component not reachable from any direct dependency". Porting a node to a root whose only parent -# edge lives in another root is exactly how that happens, so every ecosystem's smoke -# test runs this. Kept in its own file, rather than a heredoc inside each smoke-test.sh, so the unit -# suite can run it over synthetic records without a build tool or a JDK. -# -# Usage: assert-reachability.py +# component not reachable from any direct dependency". An edge onto a node its own root does not +# hold is how that happens, so every ecosystem's smoke test runs this. Kept in its own file, rather +# than a heredoc inside each smoke-test.sh, so the unit suite can run it over synthetic records +# without a build tool or a JDK. +import argparse import sys -rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +parser = argparse.ArgumentParser() +parser.add_argument('--per-project', action='store_true') +parser.add_argument('records') +args = parser.parse_args() + +rows = [l.rstrip('\n').split('\t') for l in open(args.records) if l.strip()] +project_of = {r[1]: r[2] for r in rows if r[0] == 'root'} +label = 'project' if args.per_project else 'root' + +ids_by_root = {} +for r in rows: + if r[0] == 'node': + project = r[9] if args.per_project and len(r) > 9 else '' + ids_by_root.setdefault(r[1], {})[r[2]] = project or r[2] -nodes_by_root, direct_by_root, edges_by_root = {}, {}, {} +coords, direct, adjacency = {}, {}, {} for r in rows: + if r[0] not in ('node', 'edge'): + continue + group = project_of.get(r[1], '') if args.per_project else r[1] + ids = ids_by_root.get(r[1], {}) if r[0] == 'node': - nodes_by_root.setdefault(r[1], set()).add(r[2]) + coords.setdefault(group, set()).add(ids[r[2]]) if r[8] == '1': - direct_by_root.setdefault(r[1], set()).add(r[2]) - elif r[0] == 'edge': - edges_by_root.setdefault(r[1], set()).add((r[2], r[3])) + direct.setdefault(group, set()).add(ids[r[2]]) + elif r[2] in ids and r[3] in ids: + adjacency.setdefault(group, {}).setdefault(ids[r[2]], set()).add(ids[r[3]]) errors = [] -for root_id, coords in nodes_by_root.items(): - adjacency = {} - for parent, child in edges_by_root.get(root_id, ()): - adjacency.setdefault(parent, set()).add(child) - seen = set(direct_by_root.get(root_id, ())) +for group, nodes in coords.items(): + seen = set(direct.get(group, ())) stack = list(seen) while stack: - for child in adjacency.get(stack.pop(), ()): + for child in adjacency.get(group, {}).get(stack.pop(), ()): if child not in seen: seen.add(child) stack.append(child) - unreachable = sorted(coords - seen) + unreachable = sorted(nodes - seen) if unreachable: - errors.append(f"root {root_id}: unreachable from any direct dependency: {unreachable}") + errors.append(f"{label} {group}: unreachable from any direct dependency: {unreachable}") if errors: print("FAIL:") for e in errors: print(" -", e) sys.exit(1) -print(f"PASS (reachability): {len(nodes_by_root)} root(s), all components reachable from a direct dep") +print(f"PASS (reachability): {len(coords)} {label}(s), all components reachable from a direct dep") 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 66202e5e72..e98ecbf26e 100755 --- a/src/commands/manifest/scripts/test/maven-compat/assert-records.py +++ b/src/commands/manifest/scripts/test/maven-compat/assert-records.py @@ -23,7 +23,7 @@ errors = [] -# NOTE: the generic "every component is reachable from a direct dependency of its root" invariant +# NOTE: the generic "every component is reachable from a direct dependency of its project" invariant # lives in ../assert-reachability.py, which smoke-test.sh runs alongside this file. What remains here # is Maven-specific: which coordinate lands in which root, and jar materialization. @@ -98,6 +98,14 @@ def edge_pair(parent, child): if pair and not any(root_projects.get(rid) == 'demo:mid:1.0' for rid in edge_roots(*pair)): errors.append("edge demo.ext:med-io -> demo.ext:med-util missing from mid") +# A managed scope applies to a transitive dependency as it is: demo.ext:managed is compile under +# its test parent, so it is on the runtime classpath. +pair = edge_pair('demo.ext:mgd-test:jar:', 'demo.ext:managed:jar:') +if pair: + if in_prod(pair[0]): errors.append("test dep demo.ext:mgd-test wrongly in a prod root") + elif not in_prod(pair[1]): errors.append("managed-scope demo.ext:managed not in a prod root") + elif not edge_roots(*pair): errors.append("edge demo.ext:mgd-test -> demo.ext:managed missing") + # An edge across the prod/dev split: container-lib (compile) onto container-api, which app # declares provided. The edge is kept and container-api stays dev. pair = edge_pair('demo.ext:container-lib:jar:', 'demo.ext:container-api:jar:') 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 cd2250b4a8..557f557163 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 @@ -7,6 +7,18 @@ 1.0 app + + + + + demo.ext + managed + 1.0 + compile + + +