From 1006805027d9bba409527381929459ecc5898afb Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Thu, 3 Sep 2026 20:22:46 +0700 Subject: [PATCH] fleetd #131: enforce package boundaries with an ArchUnit cycle test Adds PackageCyclesTest, which fails the build on any new cycle between the top-level dev.ltms.fleet.* packages. Today's five real cycles are recorded as narrow, explicit exceptions (ignoreDependency per named pair, both directions), each commented with the ticket step (or a note that it needs its own) that removes it. No package moves in this PR. archunit-junit5 1.5.0 (current stable, newer than an earlier 1.4.1 draft). Main code only (DO_NOT_INCLUDE_TESTS) and importPackages(...) instead of a working-directory-relative target/classes path. --- fleetd/pom.xml | 9 ++ .../dev/ltms/fleet/PackageCyclesTest.java | 96 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 fleetd/src/test/java/dev/ltms/fleet/PackageCyclesTest.java diff --git a/fleetd/pom.xml b/fleetd/pom.xml index 8dd8be0..5f5f8b4 100644 --- a/fleetd/pom.xml +++ b/fleetd/pom.xml @@ -29,6 +29,7 @@ 1.27.1 3.18.0 3.53.4.0 + 1.5.0 + + com.tngtech.archunit + archunit-junit5 + ${archunit.version} + test + diff --git a/fleetd/src/test/java/dev/ltms/fleet/PackageCyclesTest.java b/fleetd/src/test/java/dev/ltms/fleet/PackageCyclesTest.java new file mode 100644 index 0000000..46f7b3e --- /dev/null +++ b/fleetd/src/test/java/dev/ltms/fleet/PackageCyclesTest.java @@ -0,0 +1,96 @@ +package dev.ltms.fleet; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClass.Predicates; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.library.dependencies.SliceRule; +import com.tngtech.archunit.library.dependencies.SlicesRuleDefinition; +import org.junit.jupiter.api.Test; + +/** + * fleetd #131 (CB-627): enforce package boundaries with an ArchUnit test instead of a + * Maven module split. + * + *

This test fails the build the moment a NEW cycle appears between the top-level + * {@code dev.ltms.fleet.*} packages. Today's cycles are recorded below as explicit, + * narrow exceptions: each one ignores dependencies between exactly the two named + * packages, in both directions, and nothing else. A cycle through any other pair of + * packages -- or a brand new pair -- still fails this test. + * + *

Main code only. The import excludes test classes + * ({@link ImportOption.Predefined#DO_NOT_INCLUDE_TESTS}). Test code legitimately wires + * across many packages for setup and mocking; that is not part of the shipped + * architecture this rule protects. Verified: importing test classes too pulls in a much + * larger, noisier cycle set -- {@code herdr}, {@code member}, {@code peer}, {@code + * config}, {@code guard} and {@code placement} all show up in cycles that disappear the + * moment test classes are excluded. Scanning off the classpath via {@code + * importPackages(...)} (not a hardcoded {@code target/classes} path) also keeps this + * test correct regardless of the working directory the build is invoked from. + * + *

No package moves here -- ticket #131 is explicit that removing a cycle is + * its own, later PR. See the comment on each exception below for which ticket step + * removes it. + */ +class PackageCyclesTest { + + @Test + void packagesAreFreeOfCycles() { + var classes = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages("dev.ltms.fleet"); + + SliceRule rule = SlicesRuleDefinition.slices() + .matching("dev.ltms.fleet.(*)..") + .should().beFreeOfCycles(); + + // fleetd #131 step 1: move ConnectionIdentity so authz stops depending on the + // MCP layer. Evidence: auth/CallerResolver.java:3 imports mcp.ConnectionIdentity; + // mcp/FleetMcp.java:3-7 imports auth.AuditLog, Authz, CallerResolver, Principal, + // Role. + rule = ignoreCycle(rule, "auth", "mcp"); + + // fleetd #131 step 2: PrimaryRegistry is used by loops in msg; move it, or put + // an interface between msg and mcp. Evidence: msg/ReplyPushLoop.java:5 and + // msg/LeadHeartbeatLoop.java:5 import mcp.PrimaryRegistry; mcp/FleetMcp.java:15-18 + // imports msg.LeadChannel, LeadMessage, MessageService, Rendezvous. + rule = ignoreCycle(rule, "mcp", "msg"); + + // fleetd #131 -- found while implementing this test, NOT one of the ticket's + // original three; it names its own follow-up step before removal. Evidence: + // inject/CompletionResolver.java:4-5, inject/Injector.java:6 and + // inject/TurnListener.java:3 import msg.Rendezvous / msg.TurnToken; + // msg/MessageService.java:6 imports inject.Injector. + rule = ignoreCycle(rule, "inject", "msg"); + + // fleetd #131 -- same as above, its own follow-up. Evidence: + // metrics/FleetMetrics.java:3 imports msg.ReplyInbox; msg/MessageService.java:7-8, + // msg/LeadHeartbeatLoop.java:6-7 and msg/ReplyPushLoop.java:6-7 import + // metrics.FleetMetrics / metrics.Metrics. + rule = ignoreCycle(rule, "metrics", "msg"); + + // fleetd #131 -- same as above, its own follow-up. Evidence: + // session/SessionManager.java:7 imports msg.TurnToken; + // msg/LeadHeartbeatLoop.java:8 imports session.MemberSession. + rule = ignoreCycle(rule, "msg", "session"); + + rule.check(classes); + } + + /** + * Accepts today's known cycle between two top-level packages, and nothing else. + * Ignoring both directions removes exactly this pair from cycle detection; every + * other dependency -- including any new one added later, between these same two + * packages or any other pair -- is still checked. + */ + private static SliceRule ignoreCycle(SliceRule rule, String packageA, String packageB) { + return rule + .ignoreDependency(residesIn(packageA), residesIn(packageB)) + .ignoreDependency(residesIn(packageB), residesIn(packageA)); + } + + private static DescribedPredicate residesIn(String topLevelPackage) { + return Predicates.resideInAPackage("dev.ltms.fleet." + topLevelPackage + ".."); + } +}