From 75bf889f108ff4c3009bcfbbe148f4a65ac849ea Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 31 Jul 2026 11:16:31 +0200 Subject: [PATCH 1/2] Enforce module boundaries with Modulith and ArchUnit (#76) Add test-scope guardrails so plugins/telegram, plugins/discord, plugins/brave, plugins/playwright, providers/openai and providers/anthropic (and siblings) can't accidentally depend on each other, and core stays free of plugin/provider dependencies: - ApplicationModules.of(JavaClawApplication.class).verify() catches cycles between top-level modules. - An ArchUnit test enforces channel-plugin, tool-plugin and LLM-provider sibling independence, provider/plugin separation, and core independence from plugins and providers. --- app/build.gradle | 2 + .../java/ai/javaclaw/ModularityTests.java | 14 +++++ .../ai/javaclaw/ModuleBoundaryArchTest.java | 59 +++++++++++++++++++ gradle/libs.versions.toml | 3 + 4 files changed, 78 insertions(+) create mode 100644 app/src/test/java/ai/javaclaw/ModularityTests.java create mode 100644 app/src/test/java/ai/javaclaw/ModuleBoundaryArchTest.java diff --git a/app/build.gradle b/app/build.gradle index 22e2ce0..b05b3d6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,6 +34,8 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-websocket-test' testImplementation 'org.springframework.boot:spring-boot-testcontainers' testImplementation 'org.testcontainers:testcontainers-junit-jupiter' + testImplementation libs.spring.modulith.starter.test + testImplementation libs.archunit.junit5 } bootRun { diff --git a/app/src/test/java/ai/javaclaw/ModularityTests.java b/app/src/test/java/ai/javaclaw/ModularityTests.java new file mode 100644 index 0000000..d82b593 --- /dev/null +++ b/app/src/test/java/ai/javaclaw/ModularityTests.java @@ -0,0 +1,14 @@ +package ai.javaclaw; + +import org.junit.jupiter.api.Test; +import org.springframework.modulith.core.ApplicationModules; + +class ModularityTests { + + ApplicationModules modules = ApplicationModules.of(JavaClawApplication.class); + + @Test + void verifiesModularStructure() { + modules.verify(); + } +} \ No newline at end of file diff --git a/app/src/test/java/ai/javaclaw/ModuleBoundaryArchTest.java b/app/src/test/java/ai/javaclaw/ModuleBoundaryArchTest.java new file mode 100644 index 0000000..e3a2af0 --- /dev/null +++ b/app/src/test/java/ai/javaclaw/ModuleBoundaryArchTest.java @@ -0,0 +1,59 @@ +package ai.javaclaw; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; + +@AnalyzeClasses(packages = "ai.javaclaw", importOptions = ImportOption.DoNotIncludeTests.class) +class ModuleBoundaryArchTest { + + private static final String[] CORE_PACKAGES = { + "..agent..", "..tasks..", "..configuration..", "..files.." + }; + + private static final String[] PLUGIN_PACKAGES = { + "..channels.telegram..", "..channels.discord..", "..tools.brave..", "..tools.playwright.." + }; + + private static final String[] PROVIDER_PACKAGES = { + "..providers.openai..", "..providers.anthropic..", "..providers.ollama..", "..providers.google.." + }; + + @ArchTest + static final ArchRule channelPluginsAreIndependent = + slices().matching("..channels.(*)..").should().notDependOnEachOther(); + + @ArchTest + static final ArchRule toolPluginsAreIndependent = + slices().matching("..tools.(*)..").should().notDependOnEachOther(); + + @ArchTest + static final ArchRule llmProvidersAreIndependent = + slices().matching("..providers.(*)..").should().notDependOnEachOther(); + + @ArchTest + static final ArchRule providersDoNotDependOnPlugins = + noClasses().that().resideInAnyPackage(PROVIDER_PACKAGES) + .should().dependOnClassesThat().resideInAnyPackage(PLUGIN_PACKAGES); + + @ArchTest + static final ArchRule pluginsDoNotDependOnProviders = + noClasses().that().resideInAnyPackage(PLUGIN_PACKAGES) + .should().dependOnClassesThat().resideInAnyPackage(PROVIDER_PACKAGES); + + @ArchTest + static final ArchRule coreDoesNotDependOnPluginsOrProviders = + noClasses().that().resideInAnyPackage(CORE_PACKAGES) + .should().dependOnClassesThat().resideInAnyPackage(concat(PLUGIN_PACKAGES, PROVIDER_PACKAGES)); + + private static String[] concat(String[] first, String[] second) { + String[] result = new String[first.length + second.length]; + System.arraycopy(first, 0, result, 0, first.length); + System.arraycopy(second, 0, result, first.length, second.length); + return result; + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f1e8d63..cdc19d4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ spring-boot = "4.0.6" spring-ai = "2.0.0" telegrambots = "9.5.0" commonmark = "0.28.0" +archunit = "1.4.2" [libraries] bom-spring-boot = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "spring-boot" } @@ -10,6 +11,8 @@ bom-spring-ai = { module = "org.springframework.ai:spring-ai-bom", version.ref = bom-spring-modulith = { module = "org.springframework.modulith:spring-modulith-bom", version = "2.0.6" } spring-ai-agent-utils = { module = "org.springaicommunity:spring-ai-agent-utils", version = "0.9.0" } spring-ai-lucene = { module = "org.apache.lucene:lucene-core", version = "9.12.3" } +spring-modulith-starter-test = { module = "org.springframework.modulith:spring-modulith-starter-test" } +archunit-junit5 = { module = "com.tngtech.archunit:archunit-junit5", version.ref = "archunit" } netty-resolver-dns-native-macos = { module = "io.netty:netty-resolver-dns-native-macos", version = "4.2.12.Final" } jobrunr-spring-starter = { module = "org.jobrunr:jobrunr-spring-boot-4-starter", version = "8.6.1" } From 914ecb70d649f7cfbf649e8c1e2fa46b88c992d0 Mon Sep 17 00:00:00 2001 From: Marcos Date: Tue, 4 Aug 2026 10:41:09 +0200 Subject: [PATCH 2/2] Address review feedback on module boundary ArchUnit rules Anchor package patterns to the root package so they can't accidentally match unrelated packages sharing a segment name, and match plugin/provider groups by package structure instead of enumerating each implementation, so new plugins are covered automatically without editing this test. Naively matching whole plugin packages (e.g. "ai.javaclaw.channels..") briefly flagged TaskHandler for depending on the shared Channel/ ChannelRegistry abstractions that live in that same root package. Scoped the pattern to plugin subpackages only (ai.javaclaw.channels.*..) so shared interfaces stay reachable from core while plugin implementations remain isolated. --- .../ai/javaclaw/ModuleBoundaryArchTest.java | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/app/src/test/java/ai/javaclaw/ModuleBoundaryArchTest.java b/app/src/test/java/ai/javaclaw/ModuleBoundaryArchTest.java index e3a2af0..7d76a18 100644 --- a/app/src/test/java/ai/javaclaw/ModuleBoundaryArchTest.java +++ b/app/src/test/java/ai/javaclaw/ModuleBoundaryArchTest.java @@ -5,6 +5,8 @@ import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; +import java.util.stream.Stream; + import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; @@ -12,15 +14,15 @@ class ModuleBoundaryArchTest { private static final String[] CORE_PACKAGES = { - "..agent..", "..tasks..", "..configuration..", "..files.." + "ai.javaclaw.agent..", "ai.javaclaw.tasks..", "ai.javaclaw.configuration..", "ai.javaclaw.files.." }; private static final String[] PLUGIN_PACKAGES = { - "..channels.telegram..", "..channels.discord..", "..tools.brave..", "..tools.playwright.." + "ai.javaclaw.channels.*..", "ai.javaclaw.tools.*.." }; private static final String[] PROVIDER_PACKAGES = { - "..providers.openai..", "..providers.anthropic..", "..providers.ollama..", "..providers.google.." + "ai.javaclaw.providers.." }; @ArchTest @@ -48,12 +50,7 @@ class ModuleBoundaryArchTest { @ArchTest static final ArchRule coreDoesNotDependOnPluginsOrProviders = noClasses().that().resideInAnyPackage(CORE_PACKAGES) - .should().dependOnClassesThat().resideInAnyPackage(concat(PLUGIN_PACKAGES, PROVIDER_PACKAGES)); - - private static String[] concat(String[] first, String[] second) { - String[] result = new String[first.length + second.length]; - System.arraycopy(first, 0, result, 0, first.length); - System.arraycopy(second, 0, result, first.length, second.length); - return result; - } + .should().dependOnClassesThat().resideInAnyPackage( + Stream.concat(Stream.of(PLUGIN_PACKAGES), Stream.of(PROVIDER_PACKAGES)) + .toArray(String[]::new)); } \ No newline at end of file