From 9388607cf18bc26376591427b8c8589dda5e4c8f Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Tue, 27 Jan 2026 07:18:25 +0100 Subject: [PATCH 01/46] chore: prepare release 0.6.0 --- CHANGELOG.md | 8 ++++++++ README.md | 6 +++--- jbct-cli/pom.xml | 2 +- jbct-core/pom.xml | 2 +- jbct-maven-plugin/pom.xml | 2 +- pom.xml | 4 ++-- slice-processor-tests/pom.xml | 2 +- slice-processor/pom.xml | 2 +- 8 files changed, 18 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34c7e13..d02a7d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.6.0] - 2026-01-27 + +### Added + +### Changed + +### Fixed + ## [0.5.0] - 2026-01-20 ### Added diff --git a/README.md b/README.md index 5fafd7f..18cdcde 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Add to your `pom.xml`: org.pragmatica-lite jbct-maven-plugin - 0.5.0 + 0.6.0 @@ -242,7 +242,7 @@ Add executions to run automatically: org.pragmatica-lite jbct-maven-plugin - 0.5.0 + 0.6.0 check @@ -263,7 +263,7 @@ All formatting and linting settings are shared between CLI and Maven plugin. org.pragmatica-lite jbct-maven-plugin - 0.5.0 + 0.6.0 false diff --git a/jbct-cli/pom.xml b/jbct-cli/pom.xml index 6796cde..1ecf7c3 100644 --- a/jbct-cli/pom.xml +++ b/jbct-cli/pom.xml @@ -7,7 +7,7 @@ org.pragmatica-lite jbct-parent - 0.5.0 + 0.6.0 jbct-cli diff --git a/jbct-core/pom.xml b/jbct-core/pom.xml index 3c481f4..b41b278 100644 --- a/jbct-core/pom.xml +++ b/jbct-core/pom.xml @@ -7,7 +7,7 @@ org.pragmatica-lite jbct-parent - 0.5.0 + 0.6.0 jbct-core diff --git a/jbct-maven-plugin/pom.xml b/jbct-maven-plugin/pom.xml index 68405d9..9a0676f 100644 --- a/jbct-maven-plugin/pom.xml +++ b/jbct-maven-plugin/pom.xml @@ -7,7 +7,7 @@ org.pragmatica-lite jbct-parent - 0.5.0 + 0.6.0 jbct-maven-plugin diff --git a/pom.xml b/pom.xml index 92ecafc..79167db 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ org.pragmatica-lite jbct-parent - 0.5.0 + 0.6.0 pom JBCT Tools Parent @@ -318,7 +318,7 @@ org.pragmatica-lite jbct-maven-plugin - 0.5.0 + 0.6.0 check diff --git a/slice-processor-tests/pom.xml b/slice-processor-tests/pom.xml index d270a58..9bb7147 100644 --- a/slice-processor-tests/pom.xml +++ b/slice-processor-tests/pom.xml @@ -8,7 +8,7 @@ org.pragmatica-lite jbct-parent - 0.5.0 + 0.6.0 slice-processor-tests diff --git a/slice-processor/pom.xml b/slice-processor/pom.xml index 75d4f34..b55909e 100644 --- a/slice-processor/pom.xml +++ b/slice-processor/pom.xml @@ -7,7 +7,7 @@ org.pragmatica-lite jbct-parent - 0.5.0 + 0.6.0 slice-processor From 804b2454764511bb1358ad88eaccdbdab33ae941 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Tue, 27 Jan 2026 07:20:45 +0100 Subject: [PATCH 02/46] chore: bump Aether to 0.8.1 and re-enable slice-processor-tests --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 2 ++ pom.xml | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c66999..55342d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,4 +18,4 @@ jobs: distribution: 'temurin' cache: maven - - run: mvn verify -B -Djbct.skip -pl '!slice-processor-tests' + - run: mvn verify -B -Djbct.skip diff --git a/CHANGELOG.md b/CHANGELOG.md index d02a7d2..4b02640 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added ### Changed +- Build: bump Aether to 0.8.1 +- CI: re-enabled slice-processor-tests module ### Fixed diff --git a/pom.xml b/pom.xml index 79167db..29267f8 100644 --- a/pom.xml +++ b/pom.xml @@ -54,7 +54,7 @@ 0.11.1 - 0.7.5 + 0.8.1 4.7.6 3.9.9 3.15.1 From 1349326d9cad0e50b5203814ec8b5f0b35525b99 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 00:37:47 +0100 Subject: [PATCH 03/46] fix: update slice init templates for 0.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Java version 21 → 25 - Default versions: Pragmatica 0.11.1, Aether 0.8.1, JBCT 0.6.0 - Implementation pattern: record-based nested in interface - Factory accepts Config dependency parameter - Removed separate *Impl.java, SampleRequest.java, SampleResponse.java - Request/Response/Error records nested in @Slice interface Co-Authored-By: Claude Sonnet 4.5 --- CHANGELOG.md | 6 + .../jbct/init/GitHubVersionResolver.java | 6 +- .../jbct/init/SliceProjectInitializer.java | 105 ++++++++---------- .../init/SliceProjectInitializerTest.java | 10 +- 4 files changed, 64 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b02640..362c74a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ ### Changed - Build: bump Aether to 0.8.1 - CI: re-enabled slice-processor-tests module +- Slice init: Java version 21 → 25 +- Slice init: updated default versions (Pragmatica Lite 0.11.1, Aether 0.8.1, JBCT 0.6.0) +- Slice init: implementation pattern changed to record-based (nested record in interface) +- Slice init: factory now accepts Config dependency parameter +- Slice init: removed separate *Impl.java, SampleRequest.java, SampleResponse.java files +- Slice init: Request/Response/Error records now nested in @Slice interface ### Fixed diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java index f50c243..a6e4162 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java @@ -38,9 +38,9 @@ public final class GitHubVersionResolver { private static final Pattern TAG_PATTERN = Pattern.compile("\"tag_name\"\\s*:\\s*\"v?([^\"]+)\""); // Default fallback versions when offline or API fails - private static final String DEFAULT_PRAGMATICA_VERSION = "0.9.10"; - private static final String DEFAULT_AETHER_VERSION = "0.7.4"; - private static final String DEFAULT_JBCT_VERSION = "0.4.9"; + private static final String DEFAULT_PRAGMATICA_VERSION = "0.11.1"; + private static final String DEFAULT_AETHER_VERSION = "0.8.1"; + private static final String DEFAULT_JBCT_VERSION = "0.6.0"; private final HttpOperations http; private final Properties cache; diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java index 94ddda3..47922b0 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java @@ -180,15 +180,6 @@ private Result> createSourceFiles() { return Result.allOf(createFile("Slice.java.template", srcMainJava.resolve(packagePath) .resolve(sliceName + ".java")), - createFile("SliceImpl.java.template", - srcMainJava.resolve(packagePath) - .resolve(sliceName + "Impl.java")), - createFile("SampleRequest.java.template", - srcMainJava.resolve(packagePath) - .resolve("SampleRequest.java")), - createFile("SampleResponse.java.template", - srcMainJava.resolve(packagePath) - .resolve("SampleResponse.java")), createFile("SliceTest.java.template", srcTestJava.resolve(packagePath) .resolve(sliceName + "Test.java"))); @@ -261,9 +252,6 @@ private Option getInlineTemplate(String templateName) { case "gitignore.template" -> GITIGNORE_TEMPLATE; case "CLAUDE.md" -> CLAUDE_MD_TEMPLATE; case "Slice.java.template" -> SLICE_INTERFACE_TEMPLATE; - case "SliceImpl.java.template" -> SLICE_IMPL_TEMPLATE; - case "SampleRequest.java.template" -> SAMPLE_REQUEST_TEMPLATE; - case "SampleResponse.java.template" -> SAMPLE_RESPONSE_TEMPLATE; case "SliceTest.java.template" -> SLICE_TEST_TEMPLATE; case "deploy-forge.sh.template" -> DEPLOY_FORGE_TEMPLATE; case "deploy-test.sh.template" -> DEPLOY_TEST_TEMPLATE; @@ -331,7 +319,7 @@ private static void makeExecutable(Path path) { UTF-8 - 21 + 25 {{pragmaticaVersion}} {{aetherVersion}} {{jbctVersion}} @@ -524,6 +512,7 @@ private static void makeExecutable(Path path) { import org.pragmatica.aether.slice.annotation.Slice; import org.pragmatica.lang.Promise; + import org.pragmatica.lang.Result; /** * {{sliceName}} slice interface. @@ -531,56 +520,54 @@ private static void makeExecutable(Path path) { @Slice public interface {{sliceName}} { - Promise process(SampleRequest request); - - static {{sliceName}} {{factoryMethodName}}() { - return new {{sliceName}}Impl(); + /** + * Sample request record. + */ + record SampleRequest(String value) { + public static Result sampleRequest(String value) { + if (value == null || value.isBlank()) { + return Result.failure(ValidationError.emptyValue()); + } + return Result.success(new SampleRequest(value)); + } } - } - """; - - private static final String SLICE_IMPL_TEMPLATE = """ - package {{basePackage}}; - - import org.pragmatica.lang.Promise; - - /** - * Implementation of {{sliceName}} slice. - */ - final class {{sliceName}}Impl implements {{sliceName}} { - @Override - public Promise process(SampleRequest request) { - var response = new SampleResponse("Processed: " + request.value()); - return Promise.successful(response); + /** + * Sample response record. + */ + record SampleResponse(String result) {} + + /** + * Configuration record for slice dependencies. + */ + record Config(String prefix) { + public static Config config(String prefix) { + return new Config(prefix); + } } - } - """; - private static final String SAMPLE_REQUEST_TEMPLATE = """ - package {{basePackage}}; + /** + * Validation error. + */ + sealed interface ValidationError { + record EmptyValue() implements ValidationError {} - /** - * Sample request for {{sliceName}} slice. - */ - public record SampleRequest(String value) { - - public static SampleRequest sampleRequest(String value) { - return new SampleRequest(value); + static ValidationError emptyValue() { + return new EmptyValue(); + } } - } - """; - - private static final String SAMPLE_RESPONSE_TEMPLATE = """ - package {{basePackage}}; - /** - * Sample response from {{sliceName}} slice. - */ - public record SampleResponse(String result) { + Promise process(SampleRequest request); - public static SampleResponse sampleResponse(String result) { - return new SampleResponse(result); + static {{sliceName}} {{factoryMethodName}}(Config config) { + record {{sliceName}}Impl(Config config) implements {{sliceName}} { + @Override + public Promise process(SampleRequest request) { + var response = new SampleResponse(config.prefix() + ": " + request.value()); + return Promise.successful(response); + } + } + return new {{sliceName}}Impl(config); } } """; @@ -594,16 +581,18 @@ public static SampleResponse sampleResponse(String result) { class {{sliceName}}Test { - private final {{sliceName}} slice = {{sliceName}}.{{factoryMethodName}}(); + private final {{sliceName}}.Config config = {{sliceName}}.Config.config("Processed"); + private final {{sliceName}} slice = {{sliceName}}.{{factoryMethodName}}(config); @Test void should_process_request() { - var request = SampleRequest.sampleRequest("test"); + var request = {{sliceName}}.SampleRequest.sampleRequest("test") + .getOrThrow(); var response = slice.process(request).await(); assertThat(response.isSuccess()).isTrue(); - response.onSuccess(r -> assertThat(r.result()).contains("test")); + response.onSuccess(r -> assertThat(r.result()).isEqualTo("Processed: test")); } } """; diff --git a/jbct-core/src/test/java/org/pragmatica/jbct/init/SliceProjectInitializerTest.java b/jbct-core/src/test/java/org/pragmatica/jbct/init/SliceProjectInitializerTest.java index 59dd0bd..ee4765f 100644 --- a/jbct-core/src/test/java/org/pragmatica/jbct/init/SliceProjectInitializerTest.java +++ b/jbct-core/src/test/java/org/pragmatica/jbct/init/SliceProjectInitializerTest.java @@ -30,7 +30,7 @@ void should_create_slice_project_structure() { .exists(); assertThat(projectDir.resolve("src/main/java/org/example/myslice/MySlice.java")) .exists(); - assertThat(projectDir.resolve("src/main/java/org/example/myslice/MySliceImpl.java")) + assertThat(projectDir.resolve("src/test/java/org/example/myslice/MySliceTest.java")) .exists(); // Slice config file assertThat(projectDir.resolve("src/main/resources/slices/MySlice.toml")) @@ -76,7 +76,13 @@ void should_generate_valid_slice_interface() throws Exception { assertThat(content) .contains("public interface InventoryService"); assertThat(content) - .contains("static InventoryService inventoryService()"); + .contains("static InventoryService inventoryService(Config config)"); + assertThat(content) + .contains("record SampleRequest"); + assertThat(content) + .contains("record SampleResponse"); + assertThat(content) + .contains("record Config"); } @Test From f25316f4d8e63614dca1c59f601bd33e196e1d7d Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 00:40:15 +0100 Subject: [PATCH 04/46] fix: remove Sample prefix and use lowercased slice name for inner record - Request/Response instead of SampleRequest/SampleResponse - Inner implementation record named after lowercased slice name (e.g., mySlice) not Impl Co-Authored-By: Claude Sonnet 4.5 --- CHANGELOG.md | 4 ++- .../jbct/init/SliceProjectInitializer.java | 26 +++++++++---------- .../init/SliceProjectInitializerTest.java | 6 +++-- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 362c74a..d3c916e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,9 @@ - Slice init: implementation pattern changed to record-based (nested record in interface) - Slice init: factory now accepts Config dependency parameter - Slice init: removed separate *Impl.java, SampleRequest.java, SampleResponse.java files -- Slice init: Request/Response/Error records now nested in @Slice interface +- Slice init: Request/Response/Error/Config records now nested in @Slice interface +- Slice init: removed "Sample" prefix from Request/Response records +- Slice init: inner implementation record uses lowercased slice name, not "Impl" ### Fixed diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java index 47922b0..ac6ba29 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java @@ -521,21 +521,21 @@ private static void makeExecutable(Path path) { public interface {{sliceName}} { /** - * Sample request record. + * Request record. */ - record SampleRequest(String value) { - public static Result sampleRequest(String value) { + record Request(String value) { + public static Result request(String value) { if (value == null || value.isBlank()) { return Result.failure(ValidationError.emptyValue()); } - return Result.success(new SampleRequest(value)); + return Result.success(new Request(value)); } } /** - * Sample response record. + * Response record. */ - record SampleResponse(String result) {} + record Response(String result) {} /** * Configuration record for slice dependencies. @@ -557,17 +557,17 @@ static ValidationError emptyValue() { } } - Promise process(SampleRequest request); + Promise process(Request request); static {{sliceName}} {{factoryMethodName}}(Config config) { - record {{sliceName}}Impl(Config config) implements {{sliceName}} { + record {{factoryMethodName}}(Config config) implements {{sliceName}} { @Override - public Promise process(SampleRequest request) { - var response = new SampleResponse(config.prefix() + ": " + request.value()); + public Promise process(Request request) { + var response = new Response(config.prefix() + ": " + request.value()); return Promise.successful(response); } } - return new {{sliceName}}Impl(config); + return new {{factoryMethodName}}(config); } } """; @@ -586,8 +586,8 @@ class {{sliceName}}Test { @Test void should_process_request() { - var request = {{sliceName}}.SampleRequest.sampleRequest("test") - .getOrThrow(); + var request = {{sliceName}}.Request.request("test") + .getOrThrow(); var response = slice.process(request).await(); diff --git a/jbct-core/src/test/java/org/pragmatica/jbct/init/SliceProjectInitializerTest.java b/jbct-core/src/test/java/org/pragmatica/jbct/init/SliceProjectInitializerTest.java index ee4765f..27fa113 100644 --- a/jbct-core/src/test/java/org/pragmatica/jbct/init/SliceProjectInitializerTest.java +++ b/jbct-core/src/test/java/org/pragmatica/jbct/init/SliceProjectInitializerTest.java @@ -78,11 +78,13 @@ void should_generate_valid_slice_interface() throws Exception { assertThat(content) .contains("static InventoryService inventoryService(Config config)"); assertThat(content) - .contains("record SampleRequest"); + .contains("record Request"); assertThat(content) - .contains("record SampleResponse"); + .contains("record Response"); assertThat(content) .contains("record Config"); + assertThat(content) + .contains("record inventoryService"); } @Test From 8982a507d673b8282848e04f9ac9f929ccc9aff7 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 00:44:59 +0100 Subject: [PATCH 05/46] docs: update version references to 0.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Java: 21 → 25 - Pragmatica Lite: 0.10.0 → 0.11.1 - Aether: 0.8.0 → 0.8.1 - JBCT: 0.5.0 → 0.6.0 Co-Authored-By: Claude Sonnet 4.5 --- docs/slice/README.md | 4 ++-- docs/slice/architecture.md | 2 +- docs/slice/reference.md | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/slice/README.md b/docs/slice/README.md index edbded8..f5e7c7b 100644 --- a/docs/slice/README.md +++ b/docs/slice/README.md @@ -67,9 +67,9 @@ mvn verify ## Requirements -- Java 21+ +- Java 25+ - Maven 3.8+ -- JBCT CLI 0.5.0+ +- JBCT CLI 0.6.0+ ## Project Structure diff --git a/docs/slice/architecture.md b/docs/slice/architecture.md index 807d395..86c8f42 100644 --- a/docs/slice/architecture.md +++ b/docs/slice/architecture.md @@ -241,7 +241,7 @@ dependency.1.version=2.1.0 # Metadata generated.timestamp=2024-01-15T10:30:00Z -processor.version=0.5.0 +processor.version=0.6.0 ``` ### Slice API Manifest (`META-INF/slice-api.properties`) diff --git a/docs/slice/reference.md b/docs/slice/reference.md index 88e6cd7..8bc55c2 100644 --- a/docs/slice/reference.md +++ b/docs/slice/reference.md @@ -139,7 +139,7 @@ config.file=slices/OrderService.toml # Metadata generated.timestamp=2024-01-15T10:30:00Z -processor.version=0.5.0 +processor.version=0.6.0 ``` ### Slice API Manifest @@ -500,10 +500,10 @@ Generated by `jbct init --slice` with all configuration inlined: jar - 21 - 0.10.0 - 0.8.0 - 0.5.0 + 25 + 0.11.1 + 0.8.1 + 0.6.0 From 8d037d2c41f5891ae41190cfcb6af416fce06ec4 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 00:48:29 +0100 Subject: [PATCH 06/46] fix: update regular project template Java version to 25 Co-Authored-By: Claude Sonnet 4.5 --- jbct-cli/src/main/resources/templates/pom.xml.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jbct-cli/src/main/resources/templates/pom.xml.template b/jbct-cli/src/main/resources/templates/pom.xml.template index afcd4c6..6c32a5f 100644 --- a/jbct-cli/src/main/resources/templates/pom.xml.template +++ b/jbct-cli/src/main/resources/templates/pom.xml.template @@ -14,7 +14,7 @@ UTF-8 - 21 + 25 {{pragmaticaVersion}} {{jbctVersion}} From bf1248ac06678cd7907f12a2c059d9d392f09f3c Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 01:00:56 +0100 Subject: [PATCH 07/46] feat(init): use running binary version as minimum for jbct version resolution Co-Authored-By: Claude Sonnet 4.5 --- .../jbct/init/GitHubVersionResolver.java | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java index a6e4162..689a231 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java @@ -6,6 +6,7 @@ import org.pragmatica.lang.Option; import org.pragmatica.lang.Result; import org.pragmatica.lang.Unit; +import org.pragmatica.lang.parse.Number; import org.pragmatica.lang.utils.Causes; import java.io.IOException; @@ -42,9 +43,25 @@ public final class GitHubVersionResolver { private static final String DEFAULT_AETHER_VERSION = "0.8.1"; private static final String DEFAULT_JBCT_VERSION = "0.6.0"; + // Running binary version (loaded from jbct-version.properties) + private static final String RUNNING_JBCT_VERSION = loadRunningVersion(); + private final HttpOperations http; private final Properties cache; + private static String loadRunningVersion() { + var props = new Properties(); + try (var is = GitHubVersionResolver.class.getResourceAsStream("/jbct-version.properties")) { + if (is != null) { + props.load(is); + return props.getProperty("version", DEFAULT_JBCT_VERSION); + } + } catch (IOException e) { + LOG.debug("Failed to load jbct-version.properties: {}", e.getMessage()); + } + return DEFAULT_JBCT_VERSION; + } + private GitHubVersionResolver(HttpOperations http) { this.http = http; this.cache = loadCache(); @@ -73,9 +90,45 @@ public String aetherVersion() { /** * Get latest jbct-cli version. + * Uses the newer of: running binary version or latest GitHub release. */ public String jbctVersion() { - return getVersion("siy", "jbct-cli", DEFAULT_JBCT_VERSION); + var githubVersion = getVersion("siy", "jbct-cli", DEFAULT_JBCT_VERSION); + return maxVersion(RUNNING_JBCT_VERSION, githubVersion); + } + + /** + * Compare two semantic versions and return the newer one. + * Assumes format: major.minor.patch + */ + private static String maxVersion(String v1, String v2) { + var parts1 = v1.split("\\."); + var parts2 = v2.split("\\."); + + for (int i = 0; i < Math.min(parts1.length, parts2.length); i++) { + final var index = i; // Make effectively final for lambda + var comparison = Number.parseInt(parts1[index]) + .flatMap(num1 -> Number.parseInt(parts2[index]) + .map(num2 -> Integer.compare(num1, num2))); + + var cmp = comparison.fold( + _ -> { + LOG.debug("Failed to parse version numbers, using v1: {} vs v2: {}", v1, v2); + return 0; // Treat as equal on parse error + }, + value -> value + ); + + if (cmp > 0) { + return v1; + } + if (cmp < 0) { + return v2; + } + } + + // If all parts are equal, prefer longer version (e.g., 1.0.0 > 1.0) + return parts1.length >= parts2.length ? v1 : v2; } private String getVersion(String owner, String repo, String defaultVersion) { From 9e040e9247d29fcbb41474ba3da160e378a2ea1f Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 01:05:13 +0100 Subject: [PATCH 08/46] fix(init): correct slice template ValidationError and Promise API - ValidationError extends Cause (required for Result.failure) - Promise.success() instead of Promise.successful() Co-Authored-By: Claude Sonnet 4.5 --- .../jbct/init/GitHubVersionResolver.java | 23 ++++++++----------- .../jbct/init/SliceProjectInitializer.java | 4 ++-- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java index 689a231..52e14ec 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java @@ -104,21 +104,17 @@ public String jbctVersion() { private static String maxVersion(String v1, String v2) { var parts1 = v1.split("\\."); var parts2 = v2.split("\\."); - for (int i = 0; i < Math.min(parts1.length, parts2.length); i++) { - final var index = i; // Make effectively final for lambda + final var index = i; + // Make effectively final for lambda var comparison = Number.parseInt(parts1[index]) .flatMap(num1 -> Number.parseInt(parts2[index]) .map(num2 -> Integer.compare(num1, num2))); - - var cmp = comparison.fold( - _ -> { - LOG.debug("Failed to parse version numbers, using v1: {} vs v2: {}", v1, v2); - return 0; // Treat as equal on parse error - }, - value -> value - ); - + var cmp = comparison.fold(_ -> { + LOG.debug("Failed to parse version numbers, using v1: {} vs v2: {}", v1, v2); + return 0; + }, + value -> value); if (cmp > 0) { return v1; } @@ -126,9 +122,10 @@ private static String maxVersion(String v1, String v2) { return v2; } } - // If all parts are equal, prefer longer version (e.g., 1.0.0 > 1.0) - return parts1.length >= parts2.length ? v1 : v2; + return parts1.length >= parts2.length + ? v1 + : v2; } private String getVersion(String owner, String repo, String defaultVersion) { diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java index ac6ba29..faf12af 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java @@ -549,7 +549,7 @@ public static Config config(String prefix) { /** * Validation error. */ - sealed interface ValidationError { + sealed interface ValidationError extends Cause { record EmptyValue() implements ValidationError {} static ValidationError emptyValue() { @@ -564,7 +564,7 @@ record {{factoryMethodName}}(Config config) implements {{sliceName}} { @Override public Promise process(Request request) { var response = new Response(config.prefix() + ": " + request.value()); - return Promise.successful(response); + return Promise.success(response); } } return new {{factoryMethodName}}(config); From 8cccf2a47b35564552e6a1a8e393c14d586b5a38 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 01:06:56 +0100 Subject: [PATCH 09/46] fix(init): add missing Cause import to slice template Co-Authored-By: Claude Sonnet 4.5 --- .../java/org/pragmatica/jbct/init/SliceProjectInitializer.java | 1 + 1 file changed, 1 insertion(+) diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java index faf12af..ddd2e38 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java @@ -511,6 +511,7 @@ private static void makeExecutable(Path path) { package {{basePackage}}; import org.pragmatica.aether.slice.annotation.Slice; + import org.pragmatica.lang.Cause; import org.pragmatica.lang.Promise; import org.pragmatica.lang.Result; From 35a3297747807d8f0245a4298823acef698370c2 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 01:08:27 +0100 Subject: [PATCH 10/46] fix(init): implement message() method in ValidationError.EmptyValue Co-Authored-By: Claude Sonnet 4.5 --- .../org/pragmatica/jbct/init/SliceProjectInitializer.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java index ddd2e38..0b63ef5 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java @@ -551,7 +551,12 @@ public static Config config(String prefix) { * Validation error. */ sealed interface ValidationError extends Cause { - record EmptyValue() implements ValidationError {} + record EmptyValue() implements ValidationError { + @Override + public String message() { + return "Value cannot be empty"; + } + } static ValidationError emptyValue() { return new EmptyValue(); From fdb4f5bbb3c9f4b8201ab688f42412f9b888efb9 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 01:10:44 +0100 Subject: [PATCH 11/46] fix(init): use unwrap() instead of getOrThrow() in test template Co-Authored-By: Claude Sonnet 4.5 --- .../java/org/pragmatica/jbct/init/SliceProjectInitializer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java index 0b63ef5..24f426c 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java @@ -593,7 +593,7 @@ class {{sliceName}}Test { @Test void should_process_request() { var request = {{sliceName}}.Request.request("test") - .getOrThrow(); + .unwrap(); var response = slice.process(request).await(); From 03f9b14f643c90c365e01986560f041c590addc9 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 01:20:47 +0100 Subject: [PATCH 12/46] feat(init): validate groupId and add annotation processor config to slice template - Add groupId validation for Java package name format - Add annotation processor configuration to maven-compiler-plugin - Remove Config dependency from slice template (slices should be self-contained) - Simplify template to parameterless factory method Co-Authored-By: Claude Sonnet 4.5 --- .../org/pragmatica/jbct/cli/InitCommand.java | 54 +++++++++++++++++++ .../jbct/init/SliceProjectInitializer.java | 33 ++++++------ 2 files changed, 72 insertions(+), 15 deletions(-) diff --git a/jbct-cli/src/main/java/org/pragmatica/jbct/cli/InitCommand.java b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/InitCommand.java index 4eed6e6..05bf49e 100644 --- a/jbct-cli/src/main/java/org/pragmatica/jbct/cli/InitCommand.java +++ b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/InitCommand.java @@ -75,6 +75,12 @@ public class InitCommand implements Callable { @Override public Integer call() { + // Validate group ID + if (!isValidPackageName(groupId)) { + System.err.println("Error: Invalid group ID '" + groupId + "'"); + System.err.println("Group ID must be a valid Java package name (e.g., com.example, org.mycompany)"); + return 1; + } // Determine project directory if (projectDir == null) { projectDir = Path.of(System.getProperty("user.dir")); @@ -219,4 +225,52 @@ private void printAiToolsResult(java.util.List installedFiles) { System.out.println("No AI tools files to install."); } } + + private static boolean isValidPackageName(String packageName) { + if (packageName == null || packageName.isBlank()) { + return false; + } + if (packageName.startsWith(".") || packageName.endsWith(".")) { + return false; + } + if (packageName.contains("..")) { + return false; + } + var segments = packageName.split("\\."); + for (var segment : segments) { + if (!isValidJavaIdentifier(segment)) { + return false; + } + } + return true; + } + + private static boolean isValidJavaIdentifier(String identifier) { + if (identifier == null || identifier.isEmpty()) { + return false; + } + if (!Character.isJavaIdentifierStart(identifier.charAt(0))) { + return false; + } + for (int i = 1; i < identifier.length(); i++) { + if (!Character.isJavaIdentifierPart(identifier.charAt(i))) { + return false; + } + } + // Reject Java keywords + return ! isJavaKeyword(identifier); + } + + private static boolean isJavaKeyword(String word) { + return switch (word) { + case "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", + "class", "const", "continue", "default", "do", "double", "else", "enum", + "extends", "final", "finally", "float", "for", "goto", "if", "implements", + "import", "instanceof", "int", "interface", "long", "native", "new", "package", + "private", "protected", "public", "return", "short", "static", "strictfp", + "super", "switch", "synchronized", "this", "throw", "throws", "transient", + "try", "void", "volatile", "while", "true", "false", "null" -> true; + default -> false; + }; + } } diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java index 24f426c..ec5b376 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java @@ -379,6 +379,19 @@ private static void makeExecutable(Path path) { org.apache.maven.plugins maven-compiler-plugin 3.14.0 + + + + org.pragmatica-lite + slice-processor + ${jbct.version} + + + + -Aslice.groupId={{groupId}} + -Aslice.artifactId={{artifactId}} + + org.apache.maven.plugins @@ -538,15 +551,6 @@ public static Result request(String value) { */ record Response(String result) {} - /** - * Configuration record for slice dependencies. - */ - record Config(String prefix) { - public static Config config(String prefix) { - return new Config(prefix); - } - } - /** * Validation error. */ @@ -565,15 +569,15 @@ static ValidationError emptyValue() { Promise process(Request request); - static {{sliceName}} {{factoryMethodName}}(Config config) { - record {{factoryMethodName}}(Config config) implements {{sliceName}} { + static {{sliceName}} {{factoryMethodName}}() { + record {{factoryMethodName}}() implements {{sliceName}} { @Override public Promise process(Request request) { - var response = new Response(config.prefix() + ": " + request.value()); + var response = new Response("Processed: " + request.value()); return Promise.success(response); } } - return new {{factoryMethodName}}(config); + return new {{factoryMethodName}}(); } } """; @@ -587,8 +591,7 @@ public Promise process(Request request) { class {{sliceName}}Test { - private final {{sliceName}}.Config config = {{sliceName}}.Config.config("Processed"); - private final {{sliceName}} slice = {{sliceName}}.{{factoryMethodName}}(config); + private final {{sliceName}} slice = {{sliceName}}.{{factoryMethodName}}(); @Test void should_process_request() { From 1da48f8728a421f323b6bad4a8dcfd6a1690ecd5 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 01:21:45 +0100 Subject: [PATCH 13/46] docs: update changelog for 0.6.0 release Co-Authored-By: Claude Sonnet 4.5 --- CHANGELOG.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3c916e..2af96e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [0.6.0] - 2026-01-27 ### Added +- Init: groupId validation for `jbct init -g` parameter (validates Java package name format) ### Changed - Build: bump Aether to 0.8.1 @@ -10,13 +11,22 @@ - Slice init: Java version 21 → 25 - Slice init: updated default versions (Pragmatica Lite 0.11.1, Aether 0.8.1, JBCT 0.6.0) - Slice init: implementation pattern changed to record-based (nested record in interface) -- Slice init: factory now accepts Config dependency parameter +- Slice init: removed Config dependency from template (factory now parameterless) +- Slice init: added annotation processor configuration to maven-compiler-plugin +- Slice init: added compilerArgs with `-Aslice.groupId` and `-Aslice.artifactId` - Slice init: removed separate *Impl.java, SampleRequest.java, SampleResponse.java files -- Slice init: Request/Response/Error/Config records now nested in @Slice interface +- Slice init: Request/Response/Error records now nested in @Slice interface - Slice init: removed "Sample" prefix from Request/Response records - Slice init: inner implementation record uses lowercased slice name, not "Impl" +- Init: version resolution uses running binary version as minimum (overrides GitHub API if newer) +- Init: version comparison uses Result-based `Number.parseInt()` from pragmatica-lite ### Fixed +- Slice init: `ValidationError` now extends `Cause` (required for `Result.failure`) +- Slice init: added missing `Cause` import to template +- Slice init: `Promise.success()` instead of `Promise.successful()` +- Slice init: implemented `message()` method in `ValidationError.EmptyValue` +- Slice init: test template now uses `.unwrap()` instead of `.getOrThrow()` ## [0.5.0] - 2026-01-20 From 44f81354fa6a03645e930c7a4750a582c0a5d045 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 01:24:06 +0100 Subject: [PATCH 14/46] docs(init): update README and tests for template changes - Update README slice template description (nested records, no Config) - Update SliceProjectInitializerTest expectations (parameterless factory) - Check for ValidationError instead of Config in generated code Co-Authored-By: Claude Sonnet 4.5 --- README.md | 5 +++-- .../pragmatica/jbct/init/SliceProjectInitializerTest.java | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 18cdcde..c3ef57d 100644 --- a/README.md +++ b/README.md @@ -140,9 +140,10 @@ Creates Maven project with: For slice projects (`--slice`), also creates: - `@Slice` annotated interface with factory method -- Implementation class -- Sample request/response records +- Nested implementation record +- Request/Response/ValidationError records (nested in interface) - Unit test +- Maven annotation processor configuration ### Update diff --git a/jbct-core/src/test/java/org/pragmatica/jbct/init/SliceProjectInitializerTest.java b/jbct-core/src/test/java/org/pragmatica/jbct/init/SliceProjectInitializerTest.java index 27fa113..a4e6614 100644 --- a/jbct-core/src/test/java/org/pragmatica/jbct/init/SliceProjectInitializerTest.java +++ b/jbct-core/src/test/java/org/pragmatica/jbct/init/SliceProjectInitializerTest.java @@ -76,13 +76,13 @@ void should_generate_valid_slice_interface() throws Exception { assertThat(content) .contains("public interface InventoryService"); assertThat(content) - .contains("static InventoryService inventoryService(Config config)"); + .contains("static InventoryService inventoryService()"); assertThat(content) .contains("record Request"); assertThat(content) .contains("record Response"); assertThat(content) - .contains("record Config"); + .contains("sealed interface ValidationError extends Cause"); assertThat(content) .contains("record inventoryService"); } From 3533b341094ab3a43c436fec2984238a1e7af75c Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 01:25:09 +0100 Subject: [PATCH 15/46] docs(slice): update quickstart to reflect current template structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update to show nested records in single interface file - Remove references to separate *Impl, *Request, *Response files - Show ValidationError extends Cause with message() implementation - Update Promise.successful() to Promise.success() - Update Java version requirement: 21+ → 25+ Co-Authored-By: Claude Sonnet 4.5 --- docs/slice/quickstart.md | 71 +++++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/docs/slice/quickstart.md b/docs/slice/quickstart.md index 9157dc3..e0614f8 100644 --- a/docs/slice/quickstart.md +++ b/docs/slice/quickstart.md @@ -4,7 +4,7 @@ Create and deploy a slice in 5 minutes. ## Prerequisites -- Java 21+ +- Java 25+ - Maven 3.8+ - JBCT CLI installed (`jbct --version` should work) @@ -16,44 +16,63 @@ cd greeting-service ``` This creates a complete slice project with: -- `@Slice` interface with factory method -- Implementation class -- Request/response records +- `@Slice` interface with nested records and factory method +- Nested implementation record +- Request/Response/ValidationError records (all nested) - Slice config (`src/main/resources/slices/GreetingService.toml`) - Unit test - Deploy scripts ## Step 2: Explore the Generated Code -**GreetingService.java** - The slice interface: +**GreetingService.java** - The complete slice (single file): ```java @Slice public interface GreetingService { - Promise greet(GreetingRequest request); - - static GreetingService greetingService() { - return new GreetingServiceImpl(); + /** + * Request record. + */ + record Request(String value) { + public static Result request(String value) { + if (value == null || value.isBlank()) { + return Result.failure(ValidationError.emptyValue()); + } + return Result.success(new Request(value)); + } } -} -``` -**GreetingRequest.java** - Input record: -```java -public record GreetingRequest(String name) {} -``` + /** + * Response record. + */ + record Response(String result) {} + + /** + * Validation error. + */ + sealed interface ValidationError extends Cause { + record EmptyValue() implements ValidationError { + @Override + public String message() { + return "Value cannot be empty"; + } + } + + static ValidationError emptyValue() { + return new EmptyValue(); + } + } -**GreetingResponse.java** - Output record: -```java -public record GreetingResponse(String message) {} -``` + Promise process(Request request); -**GreetingServiceImpl.java** - Implementation: -```java -public class GreetingServiceImpl implements GreetingService { - @Override - public Promise greet(GreetingRequest request) { - var message = "Hello, " + request.name() + "!"; - return Promise.successful(new GreetingResponse(message)); + static GreetingService greetingService() { + record greetingService() implements GreetingService { + @Override + public Promise process(Request request) { + var response = new Response("Processed: " + request.value()); + return Promise.success(response); + } + } + return new greetingService(); } } ``` From 57336a848dc8b455759cba82566ddf058a3474f6 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 01:25:55 +0100 Subject: [PATCH 16/46] docs(slice): update README project structure - Show single MySlice.java instead of separate Impl/Request/Response files - Update JAR contents description to reflect nested structure Co-Authored-By: Claude Sonnet 4.5 --- docs/slice/README.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/slice/README.md b/docs/slice/README.md index f5e7c7b..db12e88 100644 --- a/docs/slice/README.md +++ b/docs/slice/README.md @@ -84,10 +84,7 @@ my-slice/ └── src/ ├── main/java/ │ └── org/example/myslice/ - │ ├── MySlice.java # @Slice interface - │ ├── MySliceImpl.java # Implementation - │ ├── MyRequest.java # Request record - │ └── MyResponse.java # Response record + │ └── MySlice.java # @Slice interface with nested records └── test/java/ └── org/example/myslice/ └── MySliceTest.java @@ -106,4 +103,4 @@ From Maven packaging: | JAR | Contents | |-----|----------| -| `{name}.jar` | Interface, implementation, factory, request/response types, bundled dependencies (fat JAR) | +| `{name}.jar` | Interface (with nested records/implementation), factory, bundled dependencies (fat JAR) | From d743573d671f245d2f079fd2332a7a8e88e310cf Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 13:17:24 +0100 Subject: [PATCH 17/46] fix(slice): remove slice-api.properties for RFC-0004 compliance Co-Authored-By: Claude Sonnet 4.5 --- CHANGELOG.md | 9 +++ .../jbct/init/SliceProjectValidator.java | 47 ++++++++----- .../jbct/maven/CollectSliceDepsMojo.java | 66 ++++++++++--------- .../jbct/maven/PackageSlicesMojo.java | 57 +++++++++++----- .../jbct/maven/VerifySliceMojo.java | 55 ++++++++++------ .../pragmatica/jbct/slice/SliceProcessor.java | 9 +-- .../slice/generator/ManifestGenerator.java | 29 +------- .../jbct/slice/SliceProcessorTest.java | 22 +++---- 8 files changed, 164 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2af96e8..1f06bcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ ### Added - Init: groupId validation for `jbct init -g` parameter (validates Java package name format) +### Fixed +- RFC-0004 compliance: removed non-standard slice-api.properties generation +- RFC-0004 compliance: slice manifests now include `slice.interface` property +- RFC-0004 compliance: renamed `impl.artifactId` to `slice.artifactId` in manifests +- CollectSliceDepsMojo: now scans META-INF/slice/*.manifest instead of slice-api.properties +- VerifySliceMojo: validates manifest files instead of slice-api.properties +- PackageSlicesMojo: reads slice metadata from .manifest files +- SliceProjectValidator: checks for .manifest files instead of slice-api.properties + ### Changed - Build: bump Aether to 0.8.1 - CI: re-enabled slice-processor-tests module diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectValidator.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectValidator.java index fef7fc0..35d65f8 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectValidator.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectValidator.java @@ -32,7 +32,7 @@ public static SliceProjectValidator sliceProjectValidator(Path projectDir) { * @return Validation result */ public ValidationResult validate() { - return combinePartialResults(checkPomFile(), checkSliceApiProperties(), checkManifestEntries()); + return combinePartialResults(checkPomFile(), checkSliceManifests(), checkManifestEntries()); } private ValidationResult combinePartialResults(PartialResult... partials) { @@ -66,32 +66,43 @@ private PartialResult checkPomContent(String content) { return PartialResult.partialResult(List.of(), warnings); } - private PartialResult checkSliceApiProperties() { + private PartialResult checkSliceManifests() { var targetDir = projectDir.resolve("target/classes"); if (!Files.exists(targetDir)) { return PartialResult.warning("target/classes not found - run 'mvn compile' first"); } - var propsFile = targetDir.resolve("META-INF/slice-api.properties"); - if (!Files.exists(propsFile)) { - return PartialResult.error("slice-api.properties not found - ensure annotation processor is configured"); + var sliceDir = targetDir.resolve("META-INF/slice"); + if (!Files.exists(sliceDir)) { + return PartialResult.error("META-INF/slice/ directory not found - ensure annotation processor is configured"); + } + try (var files = Files.list(sliceDir)) { + var manifestFiles = files.filter(p -> p.toString() + .endsWith(".manifest")) + .toList(); + if (manifestFiles.isEmpty()) { + return PartialResult.error("No .manifest files found in META-INF/slice/"); + } + var errors = new ArrayList(); + for (var manifestFile : manifestFiles) { + loadProperties(manifestFile) + .fold(cause -> { + errors.add("Failed to read " + manifestFile.getFileName() + ": " + cause.message()); + return null; + }, props -> { + checkRequired(props, "slice.interface", errors); + checkRequired(props, "slice.artifactId", errors); + return null; + }); + } + return PartialResult.partialResult(errors, List.of()); + } catch (Exception e) { + return PartialResult.error("Failed to scan META-INF/slice/: " + e.getMessage()); } - return loadProperties(propsFile) - .fold(cause -> PartialResult.error("Failed to read slice-api.properties: " + cause.message()), - this::checkRequiredProperties); - } - - private PartialResult checkRequiredProperties(Properties props) { - var errors = new ArrayList(); - checkRequired(props, "api.artifact", errors); - checkRequired(props, "slice.artifact", errors); - checkRequired(props, "api.interface", errors); - checkRequired(props, "impl.interface", errors); - return PartialResult.partialResult(errors, List.of()); } private void checkRequired(Properties props, String key, List errors) { if (props.getProperty(key) == null) { - errors.add("Missing required property '" + key + "' in slice-api.properties"); + errors.add("Missing required property '" + key + "' in slice manifest"); } } diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java index 8a1b642..158cf3d 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java @@ -18,24 +18,24 @@ * Scans compile dependencies for slice manifests and writes interface-to-artifact mappings. * This allows the annotation processor to resolve slice dependency versions. * - *

For each dependency JAR containing META-INF/slice-api.properties, extracts: + *

For each dependency JAR containing META-INF/slice/*.manifest files, extracts: *

    - *
  • api.interface - the slice API interface fully qualified name
  • - *
  • slice.artifact - the slice artifact coordinates (groupId:artifactId)
  • + *
  • slice.interface - the slice interface fully qualified name
  • + *
  • slice.artifactId - the slice artifact ID
  • *
* *

Writes mappings to slice-deps.properties in format: *

  * # Key: interface qualified name
  * # Value: groupId:artifactId:version
- * org.example.api.InventoryService=org.example:inventory:1.0.0
+ * org.example.api.InventoryService=org.example:inventory-service:1.0.0
  * 
*/ @Mojo(name = "collect-slice-deps", defaultPhase = LifecyclePhase.GENERATE_SOURCES, requiresDependencyResolution = ResolutionScope.COMPILE) public class CollectSliceDepsMojo extends AbstractMojo { - private static final String MANIFEST_PATH = "META-INF/slice-api.properties"; + private static final String MANIFEST_DIR = "META-INF/slice/"; @Parameter(defaultValue = "${project}", readonly = true, required = true) private MavenProject project; @@ -71,31 +71,37 @@ public void execute() throws MojoExecutionException { private void extractSliceManifest(File jarFile, String version, Properties mappings) throws IOException { try (var jar = new JarFile(jarFile)) { - var entry = jar.getEntry(MANIFEST_PATH); - if (entry == null) { - return; - } - var props = new Properties(); - try (var stream = jar.getInputStream(entry)) { - props.load(stream); - } - var apiInterface = props.getProperty("api.interface"); - var implInterface = props.getProperty("impl.interface"); - var sliceArtifact = props.getProperty("slice.artifact"); - if (sliceArtifact == null) { - getLog().warn("Incomplete slice manifest in " + jarFile.getName() + ": missing slice.artifact"); - return; - } - // Value: groupId:artifactId:version - var value = sliceArtifact + ":" + version; - // Map both API and impl interfaces to support both usage patterns - if (apiInterface != null) { - mappings.setProperty(apiInterface, value); - getLog().debug("Found slice API: " + apiInterface + " -> " + value); - } - if (implInterface != null) { - mappings.setProperty(implInterface, value); - getLog().debug("Found slice impl: " + implInterface + " -> " + value); + var entries = jar.entries(); + while (entries.hasMoreElements()) { + var entry = entries.nextElement(); + var entryName = entry.getName(); + if (!entryName.startsWith(MANIFEST_DIR) || !entryName.endsWith(".manifest")) { + continue; + } + var props = new Properties(); + try (var stream = jar.getInputStream(entry)) { + props.load(stream); + } + var sliceInterface = props.getProperty("slice.interface"); + var sliceArtifactId = props.getProperty("slice.artifactId"); + if (sliceInterface == null || sliceArtifactId == null) { + getLog().warn("Incomplete slice manifest in " + jarFile.getName() + " (" + entryName + + "): missing slice.interface or slice.artifactId"); + continue; + } + // Extract groupId from base.artifact (groupId:baseArtifactId) + var baseArtifact = props.getProperty("base.artifact"); + String groupId; + if (baseArtifact != null && baseArtifact.contains(":")) { + groupId = baseArtifact.split(":")[0]; + } else { + getLog().warn("Missing or invalid base.artifact in " + jarFile.getName() + " (" + entryName + ")"); + continue; + } + // Value: groupId:artifactId:version + var value = groupId + ":" + sliceArtifactId + ":" + version; + mappings.setProperty(sliceInterface, value); + getLog().debug("Found slice: " + sliceInterface + " -> " + value); } } } diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java index 6231998..2f93cc2 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java @@ -43,7 +43,7 @@ defaultPhase = LifecyclePhase.PACKAGE, requiresDependencyResolution = ResolutionScope.COMPILE) public class PackageSlicesMojo extends AbstractMojo { - private static final String SLICE_API_PROPERTIES = "META-INF/slice-api.properties"; + private static final String SLICE_MANIFEST_DIR = "META-INF/slice/"; @Parameter(defaultValue = "${project}", readonly = true, required = true) private MavenProject project; @@ -145,25 +145,49 @@ private boolean isAetherRuntime(Artifact artifact) { } private boolean isSliceDependency(Artifact artifact) { - return readSliceManifest(artifact) != null; + var file = artifact.getFile(); + if (file == null || !file.exists() || !file.getName() + .endsWith(".jar")) { + return false; + } + try (var jar = new JarFile(file)) { + var entries = jar.entries(); + while (entries.hasMoreElements()) { + var entry = entries.nextElement(); + if (entry.getName() + .startsWith(SLICE_MANIFEST_DIR) && entry.getName() + .endsWith(".manifest")) { + return true; + } + } + return false; + } catch (IOException e) { + getLog().debug("Could not read JAR: " + file + " - " + e.getMessage()); + return false; + } } - private java.util.Properties readSliceManifest(Artifact artifact) { + private java.util.Properties readFirstSliceManifest(Artifact artifact) { var file = artifact.getFile(); if (file == null || !file.exists() || !file.getName() .endsWith(".jar")) { return null; } try (var jar = new JarFile(file)) { - var entry = jar.getEntry(SLICE_API_PROPERTIES); - if (entry == null) { - return null; - } - var props = new java.util.Properties(); - try (var stream = jar.getInputStream(entry)) { - props.load(stream); + var entries = jar.entries(); + while (entries.hasMoreElements()) { + var entry = entries.nextElement(); + if (entry.getName() + .startsWith(SLICE_MANIFEST_DIR) && entry.getName() + .endsWith(".manifest")) { + var props = new java.util.Properties(); + try (var stream = jar.getInputStream(entry)) { + props.load(stream); + } + return props; + } } - return props; + return null; } catch (IOException e) { getLog().debug("Could not read JAR: " + file + " - " + e.getMessage()); return null; @@ -176,12 +200,13 @@ private ArtifactInfo toArtifactInfo(Artifact artifact) { private ArtifactInfo toSliceArtifactInfo(Artifact artifact) { // Read slice artifact from manifest (has correct naming: groupId:artifactId-sliceName) - var props = readSliceManifest(artifact); + var props = readFirstSliceManifest(artifact); if (props != null) { - var sliceArtifact = props.getProperty("slice.artifact"); - if (sliceArtifact != null && sliceArtifact.contains(":")) { - var parts = sliceArtifact.split(":"); - return new ArtifactInfo(parts[0], parts[1], toSemverRange(artifact.getVersion())); + var sliceArtifactId = props.getProperty("slice.artifactId"); + var baseArtifact = props.getProperty("base.artifact"); + if (sliceArtifactId != null && baseArtifact != null && baseArtifact.contains(":")) { + var groupId = baseArtifact.split(":")[0]; + return new ArtifactInfo(groupId, sliceArtifactId, toSemverRange(artifact.getVersion())); } } // Fallback to Maven artifact diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/VerifySliceMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/VerifySliceMojo.java index dbb1ace..7248ba5 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/VerifySliceMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/VerifySliceMojo.java @@ -66,12 +66,12 @@ public void execute() throws MojoExecutionException, MojoFailureException { private ValidationResult validateSlice() { var manifestResult = checkManifestEntries(); - var propsResult = checkSliceApiProperties(); + var sliceManifestResult = checkSliceManifests(); var scopeResult = checkRuntimeDependencyScopes(); var sliceScopeResult = checkSliceDependencyScopes(); var errors = new ArrayList(); var warnings = new ArrayList(); - Stream.of(manifestResult, propsResult, scopeResult, sliceScopeResult) + Stream.of(manifestResult, sliceManifestResult, scopeResult, sliceScopeResult) .forEach(partial -> { errors.addAll(partial.errors()); warnings.addAll(partial.warnings()); @@ -102,23 +102,29 @@ private PartialResult checkManifestEntries() { } } - private PartialResult checkSliceApiProperties() { - var propsFile = new File(project.getBuild() - .getOutputDirectory(), - "META-INF/slice-api.properties"); - if (!propsFile.exists()) { - return PartialResult.error("slice-api.properties not found. " + "Ensure annotation processor is configured."); - } - try (var input = new FileInputStream(propsFile)) { - var props = new Properties(); - props.load(input); - var errors = new ArrayList(); - checkRequired(props, "slice.artifact", errors); - checkRequired(props, "impl.interface", errors); - return PartialResult.partialResult(errors, List.of()); - } catch (IOException e) { - return PartialResult.error("Failed to read slice-api.properties: " + e.getMessage()); + private PartialResult checkSliceManifests() { + var sliceDir = new File(project.getBuild() + .getOutputDirectory(), + "META-INF/slice"); + if (!sliceDir.exists() || !sliceDir.isDirectory()) { + return PartialResult.error("META-INF/slice/ directory not found. " + "Ensure annotation processor is configured."); + } + var manifestFiles = sliceDir.listFiles((dir, name) -> name.endsWith(".manifest")); + if (manifestFiles == null || manifestFiles.length == 0) { + return PartialResult.error("No .manifest files found in META-INF/slice/"); + } + var errors = new ArrayList(); + for (var manifestFile : manifestFiles) { + try (var input = new FileInputStream(manifestFile)) { + var props = new Properties(); + props.load(input); + checkRequired(props, "slice.interface", errors); + checkRequired(props, "slice.artifactId", errors); + } catch (IOException e) { + errors.add("Failed to read " + manifestFile.getName() + ": " + e.getMessage()); + } } + return PartialResult.partialResult(errors, List.of()); } /** @@ -178,7 +184,16 @@ private PartialResult checkSliceDependencyScopes() { private boolean isSliceArtifact(File jarFile) { try (var jar = new JarFile(jarFile)) { - return jar.getEntry("META-INF/slice-api.properties") != null; + var entries = jar.entries(); + while (entries.hasMoreElements()) { + var entry = entries.nextElement(); + if (entry.getName() + .startsWith("META-INF/slice/") && entry.getName() + .endsWith(".manifest")) { + return true; + } + } + return false; } catch (IOException e) { getLog().debug("Could not read JAR: " + jarFile + " - " + e.getMessage()); return false; @@ -187,7 +202,7 @@ private boolean isSliceArtifact(File jarFile) { private void checkRequired(Properties props, String key, List errors) { if (props.getProperty(key) == null) { - errors.add("Missing required property '" + key + "' in slice-api.properties"); + errors.add("Missing required property '" + key + "' in slice manifest"); } } diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java index 6edc31e..0c44755 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java @@ -89,10 +89,9 @@ private void processSliceInterface(TypeElement interfaceElement) { private void generateArtifacts(TypeElement interfaceElement, SliceModel sliceModel) { Result.all(generateFactory(interfaceElement, sliceModel), - generateManifest(interfaceElement, sliceModel), generateSliceManifest(interfaceElement, sliceModel), generateRoutes(interfaceElement, sliceModel)) - .map((_, _, _, _) -> Unit.unit()) + .map((_, _, _) -> Unit.unit()) .onFailure(cause -> error(interfaceElement, cause.message())); } @@ -103,12 +102,6 @@ private Result generateFactory(TypeElement interfaceElement, SliceModel sl "Generated factory: " + sliceModel.simpleName() + "Factory")); } - private Result generateManifest(TypeElement interfaceElement, SliceModel sliceModel) { - return manifestGenerator.generate(sliceModel) - .onSuccess(_ -> note(interfaceElement, - "Generated manifest: META-INF/slice-api.properties")); - } - private Result generateSliceManifest(TypeElement interfaceElement, SliceModel sliceModel) { return manifestGenerator.generateSliceManifest(sliceModel) .onSuccess(_ -> note(interfaceElement, diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/ManifestGenerator.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/ManifestGenerator.java index 8b858f9..d1c2ff2 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/ManifestGenerator.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/ManifestGenerator.java @@ -30,32 +30,6 @@ public ManifestGenerator(Filer filer, DependencyVersionResolver versionResolver, this.options = options; } - public Result generate(SliceModel model) { - try{ - var props = new Properties(); - // Slice artifact uses naming convention: {moduleArtifactId}-{sliceName} - var sliceArtifact = getSliceArtifact(model.simpleName()); - // Slice artifact - props.setProperty("slice.artifact", sliceArtifact); - // Implementation interface fully qualified name - props.setProperty("impl.interface", model.qualifiedName()); - // Metadata - props.setProperty("generated.timestamp", - Instant.now() - .toString()); - props.setProperty("processor.version", getProcessorVersion()); - // Write to META-INF/slice-api.properties - var resource = filer.createResource(StandardLocation.CLASS_OUTPUT, "", "META-INF/slice-api.properties"); - try (var writer = new OutputStreamWriter(resource.openOutputStream())) { - props.store(writer, "Slice API manifest - generated by slice-processor"); - } - return Result.success(Unit.unit()); - } catch (Exception e) { - return Causes.cause("Failed to generate manifest: " + e.getClass() - .getSimpleName() + ": " + e.getMessage()) - .result(); - } - } private String getArtifactFromEnv() { var groupId = options.getOrDefault("slice.groupId", "unknown"); @@ -87,6 +61,7 @@ public Result generateSliceManifest(SliceModel model) { var sliceName = model.simpleName(); // Slice identification props.setProperty("slice.name", sliceName); + props.setProperty("slice.interface", model.qualifiedName()); props.setProperty("slice.artifactSuffix", toKebabCase(sliceName)); props.setProperty("slice.package", model.packageName()); // Implementation classes @@ -99,7 +74,7 @@ public Result generateSliceManifest(SliceModel model) { props.setProperty("response.classes", String.join(",", responseTypes)); // Artifact coordinates props.setProperty("base.artifact", getArtifactFromEnv()); - props.setProperty("impl.artifactId", getArtifactIdFromEnv() + "-" + toKebabCase(sliceName)); + props.setProperty("slice.artifactId", getArtifactIdFromEnv() + "-" + toKebabCase(sliceName)); // Dependencies for blueprint generation var dependencies = model.dependencies(); props.setProperty("dependencies.count", diff --git a/slice-processor/src/test/java/org/pragmatica/jbct/slice/SliceProcessorTest.java b/slice-processor/src/test/java/org/pragmatica/jbct/slice/SliceProcessorTest.java index a8752f3..e746fcf 100644 --- a/slice-processor/src/test/java/org/pragmatica/jbct/slice/SliceProcessorTest.java +++ b/slice-processor/src/test/java/org/pragmatica/jbct/slice/SliceProcessorTest.java @@ -1341,7 +1341,7 @@ static TestService testService() { } @Test - void should_generate_slice_api_properties_with_correct_artifact_naming() throws Exception { + void should_generate_slice_manifest_with_correct_property_names() throws Exception { var source = JavaFileObjects.forSourceString("test.OrderService", """ package test; @@ -1370,20 +1370,20 @@ static OrderService orderService() { assertCompilation(compilation).succeeded(); - // Verify slice-api.properties was generated with correct artifact naming - var propsFile = compilation.generatedFile( + // Verify slice manifest was generated with RFC-0004 compliant properties + var manifestFile = compilation.generatedFile( javax.tools.StandardLocation.CLASS_OUTPUT, "", - "META-INF/slice-api.properties"); + "META-INF/slice/OrderService.manifest"); - assertThat(propsFile.isPresent()).isTrue(); + assertThat(manifestFile.isPresent()).isTrue(); - var propsContent = propsFile.orElseThrow() - .getCharContent(false) - .toString(); + var manifestContent = manifestFile.orElseThrow() + .getCharContent(false) + .toString(); - // Slice artifact should use naming convention: {moduleArtifactId}-{sliceName} - // e.g., orders-order-service (not just "orders") - assertThat(propsContent).contains("slice.artifact=org.example\\:orders-order-service"); + // Verify RFC-0004 compliant properties + assertThat(manifestContent).contains("slice.interface=test.OrderService"); + assertThat(manifestContent).contains("slice.artifactId=orders-order-service"); } } From 6ff63162bc77607022ca62a96d6cc9c147719664 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 13:20:48 +0100 Subject: [PATCH 18/46] style: format code after RFC-0004 changes Co-Authored-By: Claude Sonnet 4.5 --- .../jbct/init/SliceProjectValidator.java | 15 ++++++++------- .../jbct/maven/CollectSliceDepsMojo.java | 2 +- .../pragmatica/jbct/maven/PackageSlicesMojo.java | 6 +++--- .../pragmatica/jbct/maven/VerifySliceMojo.java | 5 +++-- .../jbct/slice/generator/ManifestGenerator.java | 1 - 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectValidator.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectValidator.java index 35d65f8..53a73c1 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectValidator.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectValidator.java @@ -86,13 +86,14 @@ private PartialResult checkSliceManifests() { for (var manifestFile : manifestFiles) { loadProperties(manifestFile) .fold(cause -> { - errors.add("Failed to read " + manifestFile.getFileName() + ": " + cause.message()); - return null; - }, props -> { - checkRequired(props, "slice.interface", errors); - checkRequired(props, "slice.artifactId", errors); - return null; - }); + errors.add("Failed to read " + manifestFile.getFileName() + ": " + cause.message()); + return null; + }, + props -> { + checkRequired(props, "slice.interface", errors); + checkRequired(props, "slice.artifactId", errors); + return null; + }); } return PartialResult.partialResult(errors, List.of()); } catch (Exception e) { diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java index 158cf3d..b22dc3b 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java @@ -93,7 +93,7 @@ private void extractSliceManifest(File jarFile, String version, Properties mappi var baseArtifact = props.getProperty("base.artifact"); String groupId; if (baseArtifact != null && baseArtifact.contains(":")) { - groupId = baseArtifact.split(":")[0]; + groupId = baseArtifact.split(":") [0]; } else { getLog().warn("Missing or invalid base.artifact in " + jarFile.getName() + " (" + entryName + ")"); continue; diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java index 2f93cc2..a11998c 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java @@ -156,7 +156,7 @@ private boolean isSliceDependency(Artifact artifact) { var entry = entries.nextElement(); if (entry.getName() .startsWith(SLICE_MANIFEST_DIR) && entry.getName() - .endsWith(".manifest")) { + .endsWith(".manifest")) { return true; } } @@ -179,7 +179,7 @@ private java.util.Properties readFirstSliceManifest(Artifact artifact) { var entry = entries.nextElement(); if (entry.getName() .startsWith(SLICE_MANIFEST_DIR) && entry.getName() - .endsWith(".manifest")) { + .endsWith(".manifest")) { var props = new java.util.Properties(); try (var stream = jar.getInputStream(entry)) { props.load(stream); @@ -205,7 +205,7 @@ private ArtifactInfo toSliceArtifactInfo(Artifact artifact) { var sliceArtifactId = props.getProperty("slice.artifactId"); var baseArtifact = props.getProperty("base.artifact"); if (sliceArtifactId != null && baseArtifact != null && baseArtifact.contains(":")) { - var groupId = baseArtifact.split(":")[0]; + var groupId = baseArtifact.split(":") [0]; return new ArtifactInfo(groupId, sliceArtifactId, toSemverRange(artifact.getVersion())); } } diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/VerifySliceMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/VerifySliceMojo.java index 7248ba5..df3b7c0 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/VerifySliceMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/VerifySliceMojo.java @@ -107,7 +107,8 @@ private PartialResult checkSliceManifests() { .getOutputDirectory(), "META-INF/slice"); if (!sliceDir.exists() || !sliceDir.isDirectory()) { - return PartialResult.error("META-INF/slice/ directory not found. " + "Ensure annotation processor is configured."); + return PartialResult.error("META-INF/slice/ directory not found. " + + "Ensure annotation processor is configured."); } var manifestFiles = sliceDir.listFiles((dir, name) -> name.endsWith(".manifest")); if (manifestFiles == null || manifestFiles.length == 0) { @@ -189,7 +190,7 @@ private boolean isSliceArtifact(File jarFile) { var entry = entries.nextElement(); if (entry.getName() .startsWith("META-INF/slice/") && entry.getName() - .endsWith(".manifest")) { + .endsWith(".manifest")) { return true; } } diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/ManifestGenerator.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/ManifestGenerator.java index d1c2ff2..534611f 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/ManifestGenerator.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/ManifestGenerator.java @@ -30,7 +30,6 @@ public ManifestGenerator(Filer filer, DependencyVersionResolver versionResolver, this.options = options; } - private String getArtifactFromEnv() { var groupId = options.getOrDefault("slice.groupId", "unknown"); var artifactId = options.getOrDefault("slice.artifactId", "unknown"); From 74c99ed523f9497543337458d1ac5a2c2ec04a62 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 14:38:39 +0100 Subject: [PATCH 19/46] feat(slice): implement RFC-0007 infrastructure dependency handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Infrastructure dependencies (CacheService, DatabaseService, etc.) are now correctly accessed via InfraStore instead of being proxied through SliceInvokerFacade. This ensures shared singleton instances across all slices as specified in RFC-0007. Changes: - Added DependencyModel.isInfrastructure() to detect infra deps by package - Split dependency generation into infra (InfraStore) and slice (proxy) paths - Generate InfraStore.instance().flatMap() chain for infra dependencies - Only create proxy records for slice dependencies - Reduced flatMap depth: UrlShortener 13→3, Analytics 11→1 - Added imports: InfraStore, VersionedInstance, Causes, Option - Fixed closing parentheses and toResult() parameter bugs Performance impact: - 77% reduction in async chain depth for mixed dependencies - Eliminates proxy overhead for infrastructure services - Correct singleton semantics (all slices share infra instances) Co-Authored-By: Claude Sonnet 4.5 --- CHANGELOG.md | 4 + .../generator/FactoryClassGenerator.java | 204 ++++++++++++++++-- .../jbct/slice/model/DependencyModel.java | 13 ++ 3 files changed, 201 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f06bcd..cb9da7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,14 @@ - RFC-0004 compliance: removed non-standard slice-api.properties generation - RFC-0004 compliance: slice manifests now include `slice.interface` property - RFC-0004 compliance: renamed `impl.artifactId` to `slice.artifactId` in manifests +- RFC-0007 compliance: infrastructure dependencies now accessed via InfraStore instead of being proxied - CollectSliceDepsMojo: now scans META-INF/slice/*.manifest instead of slice-api.properties - VerifySliceMojo: validates manifest files instead of slice-api.properties - PackageSlicesMojo: reads slice metadata from .manifest files - SliceProjectValidator: checks for .manifest files instead of slice-api.properties +- FactoryClassGenerator: infrastructure deps (CacheService, etc.) now use InfraStore.instance().get() +- FactoryClassGenerator: only slice dependencies are proxied via SliceInvokerFacade +- FactoryClassGenerator: reduced flatMap chain depth (e.g., 13→3 for UrlShortener with mixed deps) ### Changed - Build: bump Aether to 0.8.1 diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java index ff67cb1..d16371b 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java @@ -126,6 +126,14 @@ private void generateImports(PrintWriter out, out.println("import org.pragmatica.lang.Promise;"); out.println("import org.pragmatica.lang.Unit;"); out.println("import org.pragmatica.lang.type.TypeToken;"); + // Import InfraStore and related types if we have infrastructure dependencies + if (allDeps.stream() + .anyMatch(DependencyModel::isInfrastructure)) { + out.println("import org.pragmatica.aether.infra.InfraStore;"); + out.println("import org.pragmatica.aether.infra.VersionedInstance;"); + out.println("import org.pragmatica.lang.utils.Causes;"); + out.println("import org.pragmatica.lang.Option;"); + } // Aspect-related imports if (model.hasAspects()) { out.println("import org.pragmatica.aether.slice.SliceRuntime;"); @@ -154,10 +162,17 @@ private void generateCreateMethod(PrintWriter out, Map> proxyMethodsCache) { var sliceName = model.simpleName(); var methodName = lowercaseFirst(sliceName); + // Split dependencies + var infraDeps = allDeps.stream() + .filter(DependencyModel::isInfrastructure) + .toList(); + var sliceDeps = allDeps.stream() + .filter(d -> !d.isInfrastructure()) + .toList(); out.println(" public static Promise<" + sliceName + "> " + methodName + "(Aspect<" + sliceName + "> aspect,"); out.println(" SliceInvokerFacade invoker) {"); - // Generate local proxy records for all dependencies - for (var dep : allDeps) { + // Generate local proxy records ONLY for slice dependencies + for (var dep : sliceDeps) { generateLocalProxyRecord(out, dep, proxyMethodsCache); out.println(); } @@ -168,8 +183,9 @@ private void generateCreateMethod(PrintWriter out, } // Build the creation chain if (model.hasAspects()) { - generateAspectCreateChain(out, model, allDeps, proxyMethodsCache); - } else if (allDeps.isEmpty()) { + generateAspectCreateChain(out, model, infraDeps, sliceDeps, proxyMethodsCache); + } else if (infraDeps.isEmpty() && sliceDeps.isEmpty()) { + // No dependencies at all var factoryArgs = model.dependencies() .stream() .map(DependencyModel::parameterName) @@ -179,7 +195,7 @@ private void generateCreateMethod(PrintWriter out, + ");"); out.println(" return Promise.success(aspect.apply(instance));"); } else { - generateFlatMapChain(out, model, allDeps, proxyMethodsCache); + generateMixedDependencyChain(out, model, infraDeps, sliceDeps, proxyMethodsCache); } out.println(" }"); } @@ -217,7 +233,8 @@ private void generateWrapperRecord(PrintWriter out, SliceModel model) { private void generateAspectCreateChain(PrintWriter out, SliceModel model, - List allDeps, + List infraDeps, + List sliceDeps, Map> proxyMethodsCache) { var sliceName = model.simpleName(); var wrapperName = sliceName + "Wrapper"; @@ -249,27 +266,41 @@ private void generateAspectCreateChain(PrintWriter out, out.println(" .async()"); out.println(" .flatMap(cfg -> factory.create(Cache.class, cfg).async()))"); } - // Handle all dependencies - if (!allDeps.isEmpty()) { + // Handle infra dependencies first + if (!infraDeps.isEmpty()) { + for (var infra : infraDeps) { + out.println(" .flatMap(" + (cacheVarNames.isEmpty() + ? "factory" + : cacheVarNames.getLast()) + " -> " + generateInfraStoreCall(infra) + + ")"); + } + } + // Handle slice dependencies + if (!sliceDeps.isEmpty()) { var allHandles = new ArrayList(); - for (var dep : allDeps) { + for (var dep : sliceDeps) { var methods = proxyMethodsCache.get(dep.interfaceQualifiedName()); for (var proxyMethod : methods) { allHandles.add(new HandleInfo(dep, proxyMethod)); } } + var prevVar = infraDeps.isEmpty() + ? (cacheVarNames.isEmpty() + ? "factory" + : cacheVarNames.getLast()) + : infraDeps.getLast() + .parameterName(); for (var handle : allHandles) { - out.println(" .flatMap(" + (cacheVarNames.isEmpty() - ? "factory" - : cacheVarNames.getLast()) + " -> " + generateMethodHandleCall(handle) + out.println(" .flatMap(" + prevVar + " -> " + generateMethodHandleCall(handle) + ")"); + prevVar = handle.varName(); } } // Final map to create wrapper - var lastVar = determineLastVariableName(allDeps, cacheVarNames, proxyMethodsCache); + var lastVar = determineLastVariableName(infraDeps, sliceDeps, cacheVarNames, proxyMethodsCache); out.println(" .map(" + lastVar + " -> {"); - // Instantiate dependency proxies - for (var dep : allDeps) { + // Instantiate slice dependency proxies only + for (var dep : sliceDeps) { var methods = proxyMethodsCache.get(dep.interfaceQualifiedName()); var handleArgs = methods.stream() .map(m -> dep.parameterName() + "_" + m.name) @@ -572,17 +603,23 @@ private String lowercaseFirst(String name) { * Determines the variable name to use in the final .map() lambda. * Handles empty collections safely to avoid NoSuchElementException. */ - private String determineLastVariableName(List allDeps, + private String determineLastVariableName(List infraDeps, + List sliceDeps, List cacheVarNames, Map> proxyMethodsCache) { - // Try deps first - if (!allDeps.isEmpty()) { - var lastDep = allDeps.getLast(); + // Try slice deps first + if (!sliceDeps.isEmpty()) { + var lastDep = sliceDeps.getLast(); var methods = proxyMethodsCache.get(lastDep.interfaceQualifiedName()); if (methods != null && !methods.isEmpty()) { - return methods.getLast().name + "Handle"; + return lastDep.parameterName() + "_" + methods.getLast().name; } } + // Try infra deps + if (!infraDeps.isEmpty()) { + return infraDeps.getLast() + .parameterName(); + } // Fall back to cache var names if (!cacheVarNames.isEmpty()) { return cacheVarNames.getLast(); @@ -590,4 +627,131 @@ private String determineLastVariableName(List allDeps, // Default to factory return "factory"; } + + private void generateMixedDependencyChain(PrintWriter out, + SliceModel model, + List infraDeps, + List sliceDeps, + Map> proxyMethodsCache) { + var sliceName = model.simpleName(); + // Start with InfraStore chain + if (!infraDeps.isEmpty()) { + var firstInfra = infraDeps.getFirst(); + out.println(" return " + generateInfraStoreCall(firstInfra)); + // Chain remaining infra deps + var indent = " "; + for (int i = 1; i < infraDeps.size(); i++) { + var infra = infraDeps.get(i); + var prevInfra = infraDeps.get(i - 1); + out.println(indent + ".flatMap(" + prevInfra.parameterName() + " -> " + generateInfraStoreCall(infra) + + ")"); + indent += " "; + } + // Chain slice dependency proxies if any + if (!sliceDeps.isEmpty()) { + var lastInfra = infraDeps.getLast(); + var allSliceHandles = new ArrayList(); + for (var dep : sliceDeps) { + var methods = proxyMethodsCache.get(dep.interfaceQualifiedName()); + for (var method : methods) { + allSliceHandles.add(new HandleInfo(dep, method)); + } + } + // First handle uses lastInfra as parameter + var firstHandle = allSliceHandles.getFirst(); + out.println(indent + ".flatMap(" + lastInfra.parameterName() + " -> " + generateMethodHandleCall(firstHandle)); + indent += " "; + // Remaining handles use previous handle's varName + for (int i = 1; i < allSliceHandles.size(); i++) { + var handle = allSliceHandles.get(i); + var prevHandle = allSliceHandles.get(i - 1); + out.println(indent + ".flatMap(" + prevHandle.varName() + " -> " + generateMethodHandleCall(handle)); + indent += " "; + } + // Final map with all dependencies + var lastHandle = allSliceHandles.getLast(); + out.println(indent + ".map(" + lastHandle.varName() + " -> {"); + generateDependencyInstantiation(out, indent, sliceDeps, proxyMethodsCache); + generateFactoryCall(out, indent, model); + out.println(indent + "})"); + // Close all flatMaps + for (int i = 0; i < infraDeps.size() - 1 + allSliceHandles.size(); i++) { + indent = indent.substring(4); + out.println(indent + ")"); + } + out.println(indent.substring(4) + ";"); + } else { + // Only infra deps, no slice deps + var lastInfra = infraDeps.getLast(); + out.println(indent + ".map(" + lastInfra.parameterName() + " -> {"); + generateFactoryCall(out, indent, model); + out.println(indent + "})"); + // Close all flatMaps + for (int i = 0; i < infraDeps.size() - 1; i++) { + indent = indent.substring(4); + out.println(indent + ")"); + } + out.println(indent.substring(4) + ";"); + } + } else { + // Only slice deps (existing flatMap logic) + generateFlatMapChain(out, model, sliceDeps, proxyMethodsCache); + } + } + + private String generateInfraStoreCall(DependencyModel infra) { + var interfaceName = infra.interfaceSimpleName(); + var artifactId = inferInfraArtifactId(infra); + var errorMsg = escapeJavaString(interfaceName + " not available in InfraStore"); + return "InfraStore.instance()\n" + + " .flatMap(store -> store.get(\"org.pragmatica.aether.infra:" + artifactId + + "\", " + interfaceName + ".class)\n" + + " .stream()\n" + + " .findFirst()\n" + + " .map(vi -> Option.some(vi.instance()))\n" + + " .orElse(Option.none()))\n" + + " .toResult(Causes.cause(\"" + errorMsg + "\"))\n" + + " .async()"; + } + + private String inferInfraArtifactId(DependencyModel infra) { + // Extract artifact ID from package name + // org.pragmatica.aether.infra.cache.CacheService -> "cache" + // org.pragmatica.aether.infra.database.DatabaseService -> "database" + var pkg = infra.interfacePackage(); + var parts = pkg.split("\\."); + if (parts.length >= 5 && parts[3].equals("infra")) { + return parts[4]; + } + // Fallback: lowercase interface name without "Service" + return infra.interfaceSimpleName() + .replace("Service", "") + .toLowerCase(); + } + + private void generateDependencyInstantiation(PrintWriter out, + String indent, + List sliceDeps, + Map> proxyMethodsCache) { + for (var dep : sliceDeps) { + var methods = proxyMethodsCache.get(dep.interfaceQualifiedName()); + var handleArgs = methods.stream() + .map(m -> dep.parameterName() + "_" + m.name) + .toList(); + out.println(indent + " var " + dep.parameterName() + " = new " + dep.localRecordName() + "(" + String.join(", ", + handleArgs) + + ");"); + } + } + + private void generateFactoryCall(PrintWriter out, String indent, SliceModel model) { + var sliceName = model.simpleName(); + var factoryArgs = model.dependencies() + .stream() + .map(DependencyModel::parameterName) + .toList(); + out.println(indent + " return aspect.apply(" + sliceName + "." + model.factoryMethodName() + "(" + String.join(", ", + factoryArgs) + + "));"); + } } diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/model/DependencyModel.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/model/DependencyModel.java index c3d6621..8fe59ef 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/model/DependencyModel.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/model/DependencyModel.java @@ -64,6 +64,19 @@ public Option fullArtifact() { .map((artifact, ver) -> artifact + ":" + ver); } + /** + * Checks if this dependency is an infrastructure dependency. + * Infrastructure dependencies: + * - Live under org.pragmatica.aether.infra.* package + * - Are accessed via InfraStore (singleton instances) + * - NOT proxied via SliceInvokerFacade + * + * Examples: CacheService, DatabaseService, MetricsService + */ + public boolean isInfrastructure() { + return interfaceQualifiedName.startsWith("org.pragmatica.aether.infra."); + } + /** * Get lowercase name for local proxy record (JBCT naming convention). * Handles acronyms properly: "HTTPService" -> "httpService" From c7bd16aac1caf723c703961a95f49ed08dcce6e3 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 15:11:15 +0100 Subject: [PATCH 20/46] fix(slice): correct property name for slice artifact ID SliceManifest was reading 'impl.artifactId' but manifests contain 'slice.artifactId' (per RFC-0004). This caused: - Empty JAR names: -1.0.0-SNAPSHOT.jar - JAR overwriting: all slices created same-named JAR Fixed by reading correct property name. Now generates: - url-shortener-analytics-1.0.0-SNAPSHOT.jar - url-shortener-url-shortener-1.0.0-SNAPSHOT.jar Each slice gets unique JAR with correct artifact ID. Co-Authored-By: Claude Sonnet 4.5 --- CHANGELOG.md | 3 +++ .../src/main/java/org/pragmatica/jbct/slice/SliceManifest.java | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb9da7c..de39283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ - VerifySliceMojo: validates manifest files instead of slice-api.properties - PackageSlicesMojo: reads slice metadata from .manifest files - SliceProjectValidator: checks for .manifest files instead of slice-api.properties +- SliceManifest: reads `slice.artifactId` property (was incorrectly reading `impl.artifactId`) +- PackageSlicesMojo: fixed JAR naming bug (empty artifact prefix in JAR names) +- PackageSlicesMojo: fixed JAR overwriting bug (multiple slices now create separate JARs) - FactoryClassGenerator: infrastructure deps (CacheService, etc.) now use InfraStore.instance().get() - FactoryClassGenerator: only slice dependencies are proxied via SliceInvokerFacade - FactoryClassGenerator: reduced flatMap chain depth (e.g., 13→3 for UrlShortener with mixed deps) diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceManifest.java b/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceManifest.java index 3b38259..bc4ed03 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceManifest.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceManifest.java @@ -88,7 +88,7 @@ private static Result fromProperties(Properties props) { var requestClasses = parseList(props.getProperty("request.classes", "")); var responseClasses = parseList(props.getProperty("response.classes", "")); var baseArtifact = props.getProperty("base.artifact", ""); - var implArtifactId = props.getProperty("impl.artifactId", ""); + var implArtifactId = props.getProperty("slice.artifactId", ""); var dependencies = parseDependencies(props); var configFile = props.getProperty("config.file", ""); return Result.success(new SliceManifest(sliceName, From 6a23aa4cd0092952dcaf8aefde079291f774e8a0 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 15:30:28 +0100 Subject: [PATCH 21/46] fix(slice): fix dependency file generation and blueprint resolution - Fix isAetherRuntime() filtering - was too broad, skipped all infra deps - Now only skips slice-annotations, slice-api, and slice-processor - Allow infra-* and core to be included in dependency file sections - Filter infrastructure deps from blueprint (resolved via InfraStore) - Fix local dependency resolution with package context - Analytics now resolves to url-shortener-analytics (not urlshortener) Fixes empty dependency files (0 bytes) and UNRESOLVED artifacts in blueprint Co-Authored-By: Claude Sonnet 4.5 --- .../jbct/maven/PackageSlicesMojo.java | 11 +++-- .../generator/DependencyVersionResolver.java | 41 ++++++++++++++++++- .../slice/generator/ManifestGenerator.java | 11 ++++- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java index a11998c..016743c 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java @@ -139,9 +139,14 @@ private java.util.Set collectDirectDependencyKeys() { private boolean isAetherRuntime(Artifact artifact) { var groupId = artifact.getGroupId(); - // All pragmatica-lite and aether libraries are provided by platform - // This includes core, http-routing-adapter, and all their transitives - return "org.pragmatica-lite".equals(groupId) || "org.pragmatica-lite.aether".equals(groupId); + var artifactId = artifact.getArtifactId(); + // Skip runtime libraries AND compile-only tools (slice-processor) + // Infrastructure (infra-*) and shared libs (core) should go in dependency file + if ("org.pragmatica-lite.aether".equals(groupId)) { + return artifactId.equals("slice-annotations") || artifactId.equals("slice-api"); + } + // Skip slice-processor (compile-only tool) + return "org.pragmatica-lite".equals(groupId) && artifactId.equals("slice-processor"); } private boolean isSliceDependency(Artifact artifact) { diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/DependencyVersionResolver.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/DependencyVersionResolver.java index 9681686..62f5888 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/DependencyVersionResolver.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/DependencyVersionResolver.java @@ -23,12 +23,25 @@ public class DependencyVersionResolver { private final ProcessingEnvironment env; private final Properties sliceDeps; + private String currentSlicePackage; + private String baseGroupId; + private String baseArtifactId; public DependencyVersionResolver(ProcessingEnvironment env) { this.env = env; this.sliceDeps = loadSliceDeps(); } + /** + * Set context for resolving local dependencies (same module). + * Must be called before resolving dependencies for a slice. + */ + public void setSliceContext(String slicePackage, String groupId, String artifactId) { + this.currentSlicePackage = slicePackage; + this.baseGroupId = groupId; + this.baseArtifactId = artifactId; + } + public DependencyModel resolve(DependencyModel dependency) { var interfaceFqn = dependency.interfaceQualifiedName(); if (interfaceFqn == null || interfaceFqn.isEmpty()) { @@ -66,7 +79,15 @@ private DependencyModel fallbackResolve(DependencyModel dependency) { if (interfacePackage == null || interfacePackage.isEmpty()) { return dependency.withResolved("unknown:unknown", "UNRESOLVED"); } - // Derive artifact from package: org.example.inventory.api -> org.example:inventory + // Check if this is a local dependency (same package as current slice) + if (currentSlicePackage != null && interfacePackage.equals(currentSlicePackage)) { + // Local dependency - use base artifact + slice name in kebab-case + var sliceName = dependency.interfaceSimpleName(); + var kebabName = toKebabCase(sliceName); + var artifact = baseGroupId + ":" + baseArtifactId + "-" + kebabName; + return dependency.withResolved(artifact, "UNRESOLVED"); + } + // External dependency - derive from package: org.example.inventory.api -> org.example:inventory var pkg = interfacePackage; // Remove .api suffix if present if (pkg.endsWith(".api")) { @@ -81,6 +102,24 @@ private DependencyModel fallbackResolve(DependencyModel dependency) { return dependency.withResolved(groupId + ":" + artifactId, "UNRESOLVED"); } + private static String toKebabCase(String camelCase) { + if (camelCase == null || camelCase.isEmpty()) { + return camelCase; + } + var result = new StringBuilder(); + result.append(Character.toLowerCase(camelCase.charAt(0))); + for (int i = 1; i < camelCase.length(); i++) { + char c = camelCase.charAt(i); + if (Character.isUpperCase(c)) { + result.append('-'); + result.append(Character.toLowerCase(c)); + } else { + result.append(c); + } + } + return result.toString(); + } + private Properties loadSliceDeps() { var props = new Properties(); try{ diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/ManifestGenerator.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/ManifestGenerator.java index 534611f..dd86079 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/ManifestGenerator.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/ManifestGenerator.java @@ -58,6 +58,10 @@ public Result generateSliceManifest(SliceModel model) { try{ var props = new Properties(); var sliceName = model.simpleName(); + // Set context for resolving local dependencies + var groupId = options.getOrDefault("slice.groupId", "unknown"); + var artifactId = options.getOrDefault("slice.artifactId", "unknown"); + versionResolver.setSliceContext(model.packageName(), groupId, artifactId); // Slice identification props.setProperty("slice.name", sliceName); props.setProperty("slice.interface", model.qualifiedName()); @@ -74,8 +78,11 @@ public Result generateSliceManifest(SliceModel model) { // Artifact coordinates props.setProperty("base.artifact", getArtifactFromEnv()); props.setProperty("slice.artifactId", getArtifactIdFromEnv() + "-" + toKebabCase(sliceName)); - // Dependencies for blueprint generation - var dependencies = model.dependencies(); + // Dependencies for blueprint generation (exclude infrastructure - they're resolved via InfraStore) + var dependencies = model.dependencies() + .stream() + .filter(dep -> !dep.isInfrastructure()) + .toList(); props.setProperty("dependencies.count", String.valueOf(dependencies.size())); int index = 0; From 3b2603f21173d1841c3f07952ad5070a7201dbfe Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 15:34:17 +0100 Subject: [PATCH 22/46] fix(slice): deduplicate local dependencies in blueprint Skip UNRESOLVED local dependencies when they're already in graph with correct version. Prevents duplicate entries like: - url-shortener-analytics:UNRESOLVED (from transitive dep) - url-shortener-analytics:1.0.0 (from local manifest) Blueprint now correctly shows each slice once with proper version. Co-Authored-By: Claude Sonnet 4.5 --- .../pragmatica/jbct/maven/GenerateBlueprintMojo.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/GenerateBlueprintMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/GenerateBlueprintMojo.java index cad96ff..6e561a2 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/GenerateBlueprintMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/GenerateBlueprintMojo.java @@ -127,9 +127,20 @@ private void resolveExternalDependencies(SliceManifest manifest, continue; } var depArtifact = dep.artifact() + ":" + dep.version(); + // Skip if already in graph (includes local slices and previously resolved deps) if (graph.containsKey(depArtifact)) { continue; } + // Skip UNRESOLVED local dependencies - they're already in graph with correct version + if ("UNRESOLVED".equals(dep.version())) { + var artifactWithoutVersion = dep.artifact(); + var isLocal = graph.keySet() + .stream() + .anyMatch(key -> key.startsWith(artifactWithoutVersion + ":")); + if (isLocal) { + continue; + } + } loadManifestFromDependency(dep.artifact(), dep.version()).onPresent(depManifest -> { // External dependencies use default config From 7fd74d5e7f6d1dae92658dd881d3c999677895f9 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 15:43:14 +0100 Subject: [PATCH 23/46] feat(slice): include same-module slice dependencies in [slices] section When multiple @Slice interfaces exist in same Maven module, their dependencies are now included in the [slices] section for: 1. Correct deployment ordering in blueprint 2. Runtime dependency resolution Example: UrlShortener depends on Analytics (same module) Dependency file now includes: [slices] org.pragmatica.aether.example:url-shortener-analytics:^1.0.0 Co-Authored-By: Claude Sonnet 4.5 --- .../jbct/maven/PackageSlicesMojo.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java index 016743c..13adeca 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java @@ -126,9 +126,33 @@ private DependencyClassification classifyDependencies(SliceManifest manifest) { externalDeps.add(artifact); } } + // Add same-module slice dependencies from manifest + addLocalSliceDependencies(manifest, sliceDeps); return new DependencyClassification(sharedDeps, infraDeps, sliceDeps, externalDeps); } + private void addLocalSliceDependencies(SliceManifest manifest, List sliceDeps) { + // Check manifest dependencies for local slices (same module) + for (var dep : manifest.dependencies()) { + // Local slice dependencies have artifact coordinates but may be UNRESOLVED + if (dep.artifact() == null || dep.artifact() + .isEmpty()) { + continue; + } + // Check if this is a local slice (same groupId and base artifactId) + var depArtifact = dep.artifact(); + if (depArtifact.startsWith(project.getGroupId() + ":" + project.getArtifactId() + "-")) { + // Extract slice artifact ID and create version range + var version = "^" + project.getVersion(); + sliceDeps.add(new ArtifactInfo(project.getGroupId(), + depArtifact.substring(project.getGroupId() + .length() + 1), + version)); + getLog().debug("Added local slice dependency: " + depArtifact + ":" + version); + } + } + } + private java.util.Set collectDirectDependencyKeys() { var keys = new java.util.HashSet(); for (var dep : project.getDependencies()) { From afdf429ad0c6c97224621878feadf3e072e0a5d5 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 20:50:43 +0100 Subject: [PATCH 24/46] fix(slice): resolve service file collision and add http-routing-adapter dependency - Fix SliceRouterFactory service file collision with multiple slices - Accumulate service entries in SliceProcessor, write once at end - Add http-routing-adapter to slice template with compile-time validation - Update documentation with dependency requirement Co-Authored-By: Claude Sonnet 4.5 --- CLAUDE.md | 13 ++- .../jbct/init/SliceProjectInitializer.java | 8 ++ slice-processor/docs/HTTP-ROUTE-GENERATION.md | 30 +++++++ .../pragmatica/jbct/slice/SliceProcessor.java | 84 ++++++++++++++++++- .../slice/routing/RouteSourceGenerator.java | 68 +++++++-------- 5 files changed, 164 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3da0859..f87e374 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -340,7 +340,18 @@ Note: Proxy records for dependencies are generated as local records inside the f ### HTTP Routing Generation -When `routes.toml` exists in the slice package resources, generates HTTP route handling: +When `routes.toml` exists in the slice package resources, generates HTTP route handling. + +**Dependency requirement**: New projects created with `jbct init --slice` include `http-routing-adapter` automatically. For existing projects adding routing, add: +```xml + + org.pragmatica-lite.aether + http-routing-adapter + ${aether.version} + provided + +``` +Compilation fails if `routes.toml` exists without this dependency. **Config location:** `src/main/resources/{slicePackage}/routes.toml` diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java index ec5b376..5c14536 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java @@ -356,6 +356,14 @@ private static void makeExecutable(Path path) { provided + + + org.pragmatica-lite.aether + http-routing-adapter + ${aether.version} + provided + + diff --git a/slice-processor/docs/HTTP-ROUTE-GENERATION.md b/slice-processor/docs/HTTP-ROUTE-GENERATION.md index bff8faf..43f238a 100644 --- a/slice-processor/docs/HTTP-ROUTE-GENERATION.md +++ b/slice-processor/docs/HTTP-ROUTE-GENERATION.md @@ -4,6 +4,36 @@ This document describes the automatic generation of HTTP route handling code from TOML configuration. The generated code bridges Aether slices with the `http-routing-adapter` module, enabling HTTP API exposure for slice methods. +## Dependency Requirement + +**Required dependency**: When using `routes.toml`, the `http-routing-adapter` dependency must be present. + +**New projects**: Projects created with `jbct init --slice` include this dependency automatically: + +```xml + + + org.pragmatica-lite.aether + http-routing-adapter + ${aether.version} + provided + +``` + +**Existing projects**: If adding routing to an existing slice project, add the dependency above. + +**Compile-time validation**: If `routes.toml` exists but the dependency is missing, compilation fails with: +``` +ERROR: HTTP routing configured but dependency missing. +Add to pom.xml: + + org.pragmatica-lite.aether + http-routing-adapter + ${aether.version} + provided + +``` + ## Generated Artifacts When `routes.toml` exists in the slice package resources, the processor generates: diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java index 0c44755..112cf6b 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java @@ -44,6 +44,8 @@ public class SliceProcessor extends AbstractProcessor { private DependencyVersionResolver versionResolver; private RouteSourceGenerator routeGenerator; private ErrorTypeDiscovery errorDiscovery; + private final java.util.Map packageToSlice = new java.util.HashMap<>(); + private final java.util.Set routeServiceEntries = new java.util.LinkedHashSet<>(); @Override public synchronized void init(javax.annotation.processing.ProcessingEnvironment processingEnv) { @@ -74,9 +76,47 @@ public boolean process(Set annotations, RoundEnvironment continue; } var interfaceElement = (TypeElement) element; + // Enforce one-slice-per-package convention + if (!validateOneSlicePerPackage(interfaceElement)) { + return false; + } processSliceInterface(interfaceElement); } } + // Write service file once at the end if we have route entries + if (roundEnv.processingOver() && !routeServiceEntries.isEmpty()) { + writeRouteServiceFile(); + } + return true; + } + + private boolean validateOneSlicePerPackage(TypeElement interfaceElement) { + var packageName = processingEnv.getElementUtils() + .getPackageOf(interfaceElement) + .getQualifiedName() + .toString(); + var sliceName = interfaceElement.getSimpleName() + .toString(); + if (packageToSlice.containsKey(packageName)) { + var existingSlice = packageToSlice.get(packageName); + var existingName = existingSlice.getSimpleName() + .toString(); + var message = String.format("Multiple @Slice interfaces found in package '%s': %s and %s. " + + "Convention: One slice per package. " + "Move to separate packages:\n" + + " - %s.%s (for %s)\n" + " - %s.%s (for %s)", + packageName, + existingName, + sliceName, + packageName, + toKebabCase(existingName), + existingName, + packageName, + toKebabCase(sliceName), + sliceName); + error(interfaceElement, message); + return false; + } + packageToSlice.put(packageName, interfaceElement); return true; } @@ -143,8 +183,12 @@ private Result generateRoutesFromConfig(TypeElement interfaceElement, return errorDiscovery.discover(packageName, config.errors()) .flatMap(errorMappings -> routeGenerator.generate(sliceModel, config, errorMappings)) - .onSuccess(_ -> note(interfaceElement, - "Generated routes: " + sliceModel.simpleName() + "Routes")); + .onSuccess(qualifiedNameOpt -> { + qualifiedNameOpt.onPresent(routeServiceEntries::add); + note(interfaceElement, + "Generated routes: " + sliceModel.simpleName() + "Routes"); + }) + .map(_ -> Unit.unit()); } private void error(Element element, String message) { @@ -156,4 +200,40 @@ private void note(Element element, String message) { processingEnv.getMessager() .printMessage(Diagnostic.Kind.NOTE, message, element); } + + private void writeRouteServiceFile() { + try{ + var serviceFile = processingEnv.getFiler() + .createResource(StandardLocation.CLASS_OUTPUT, + "", + "META-INF/services/org.pragmatica.aether.http.adapter.SliceRouterFactory"); + try (var writer = new java.io.PrintWriter(serviceFile.openWriter())) { + for (var entry : routeServiceEntries) { + writer.println(entry); + } + } + } catch (IOException e) { + processingEnv.getMessager() + .printMessage(Diagnostic.Kind.ERROR, + "Failed to write SliceRouterFactory service file: " + e.getMessage()); + } + } + + private static String toKebabCase(String camelCase) { + if (camelCase == null || camelCase.isEmpty()) { + return camelCase; + } + var result = new StringBuilder(); + result.append(Character.toLowerCase(camelCase.charAt(0))); + for (int i = 1; i < camelCase.length(); i++) { + char c = camelCase.charAt(i); + if (Character.isUpperCase(c)) { + result.append('-'); + result.append(Character.toLowerCase(c)); + } else { + result.append(c); + } + } + return result.toString(); + } } diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java index e4c891b..a91022c 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java @@ -10,17 +10,12 @@ import javax.annotation.processing.Filer; import javax.annotation.processing.Messager; import javax.tools.Diagnostic; -import javax.tools.FileObject; import javax.tools.JavaFileObject; -import javax.tools.StandardLocation; -import java.io.BufferedReader; import java.io.IOException; import java.io.PrintWriter; import java.util.ArrayList; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.stream.Collectors; /** @@ -67,8 +62,6 @@ * } */ public class RouteSourceGenerator { - private static final String SERVICE_FILE = "META-INF/services/org.pragmatica.aether.http.adapter.SliceRouterFactory"; - private static final Map TYPE_TO_PATH_PARAMETER = Map.ofEntries(Map.entry("String", "aString"), Map.entry("java.lang.String", "aString"), @@ -147,11 +140,21 @@ public RouteSourceGenerator(Filer filer, Messager messager) { this.messager = messager; } - public Result generate(SliceModel model, - RouteConfig routeConfig, - List errorMappings) { + /** + * Generates Routes class for a slice. + * Returns the qualified name of the generated class if routes exist, empty otherwise. + * The caller is responsible for writing the service file with all accumulated entries. + */ + public Result> generate(SliceModel model, + RouteConfig routeConfig, + List errorMappings) { if (!routeConfig.hasRoutes()) { - return Result.success(Unit.unit()); + return Result.success(Option.none()); + } + // Validate that http-routing-adapter is on classpath + var validationResult = validateHttpRoutingDependency(); + if (validationResult.isFailure()) { + return validationResult.map(_ -> Option.none()); } try{ var routesName = model.simpleName() + "Routes"; @@ -161,9 +164,7 @@ public Result generate(SliceModel model, try (var writer = new PrintWriter(file.openWriter())) { generateRoutesClass(writer, model, routeConfig, errorMappings, routesName); } - // Generate/update service loader file - generateServiceFile(qualifiedName); - return Result.success(Unit.unit()); + return Result.success(Option.some(qualifiedName)); } catch (Exception e) { return Causes.cause("Failed to generate routes class: " + e.getClass() .getSimpleName() + ": " + e.getMessage()) @@ -171,29 +172,24 @@ public Result generate(SliceModel model, } } - private void generateServiceFile(String qualifiedName) throws IOException { - // Read existing entries if file exists - Set entries = new LinkedHashSet<>(); + private Result validateHttpRoutingDependency() { try{ - FileObject existing = filer.getResource(StandardLocation.CLASS_OUTPUT, "", SERVICE_FILE); - try (var reader = new BufferedReader(existing.openReader(true))) { - String line; - while ((line = reader.readLine()) != null) { - var trimmed = line.trim(); - if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { - entries.add(trimmed); - } - } - } - } catch (IOException _) {} catch (IllegalArgumentException _) {} - // Add the new entry - entries.add(qualifiedName); - // Write all entries - FileObject serviceFile = filer.createResource(StandardLocation.CLASS_OUTPUT, "", SERVICE_FILE); - try (var writer = new PrintWriter(serviceFile.openWriter())) { - for (var entry : entries) { - writer.println(entry); - } + Class.forName("org.pragmatica.aether.http.adapter.SliceRouterFactory"); + return Result.success(Unit.unit()); + } catch (ClassNotFoundException _) { + var message = """ + HTTP routing configured but dependency missing. + Add to pom.xml: + + org.pragmatica-lite.aether + http-routing-adapter + ${aether.version} + provided + + """; + messager.printMessage(Diagnostic.Kind.ERROR, message); + return Causes.cause("Missing http-routing-adapter dependency") + .result(); } } From 25ed24f6a7a443da0e999f814a18cd52eff23068 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 20:59:45 +0100 Subject: [PATCH 25/46] fix(slice): detect same-module dependencies by base package prefix Changed DependencyVersionResolver to check if dependency package starts with base package (groupId + artifactId) instead of exact match with current slice package. This correctly identifies same-module slice dependencies for dependency file [slices] section. Before: org.pragmatica.aether.example.urlshortener:analytics After: org.pragmatica.aether.example:url-shortener-analytics Co-Authored-By: Claude Sonnet 4.5 --- .../generator/DependencyVersionResolver.java | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/DependencyVersionResolver.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/DependencyVersionResolver.java index 62f5888..34c183c 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/DependencyVersionResolver.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/DependencyVersionResolver.java @@ -79,13 +79,17 @@ private DependencyModel fallbackResolve(DependencyModel dependency) { if (interfacePackage == null || interfacePackage.isEmpty()) { return dependency.withResolved("unknown:unknown", "UNRESOLVED"); } - // Check if this is a local dependency (same package as current slice) - if (currentSlicePackage != null && interfacePackage.equals(currentSlicePackage)) { - // Local dependency - use base artifact + slice name in kebab-case - var sliceName = dependency.interfaceSimpleName(); - var kebabName = toKebabCase(sliceName); - var artifact = baseGroupId + ":" + baseArtifactId + "-" + kebabName; - return dependency.withResolved(artifact, "UNRESOLVED"); + // Check if this is a same-module dependency (shares base package) + if (baseGroupId != null && baseArtifactId != null) { + // Compute base package: groupId + artifactId (with dashes removed) + var basePackage = baseGroupId + "." + baseArtifactId.replace("-", ""); + if (interfacePackage.startsWith(basePackage + ".")) { + // Same-module dependency - use base artifact + slice name in kebab-case + var sliceName = dependency.interfaceSimpleName(); + var kebabName = toKebabCase(sliceName); + var artifact = baseGroupId + ":" + baseArtifactId + "-" + kebabName; + return dependency.withResolved(artifact, "UNRESOLVED"); + } } // External dependency - derive from package: org.example.inventory.api -> org.example:inventory var pkg = interfacePackage; From 95fbf2312d973e45680eebf1078f08992cb24655 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 21:12:06 +0100 Subject: [PATCH 26/46] refactor(slice): move http-routing-adapter validation to maven plugin Moved dependency validation from annotation processor to CollectSliceDepsMojo: - Removed Class.forName() check from RouteSourceGenerator (wrong classpath) - Added validation in CollectSliceDepsMojo.validateHttpRoutingDependency() - Scans for routes.toml files in src/main/resources - Checks project dependencies for http-routing-adapter - Fails early (generate-sources phase) with clear error message Co-Authored-By: Claude Sonnet 4.5 --- .../jbct/maven/CollectSliceDepsMojo.java | 58 +++++++++++++++++++ .../slice/routing/RouteSourceGenerator.java | 26 --------- 2 files changed, 58 insertions(+), 26 deletions(-) diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java index b22dc3b..1380cb2 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java @@ -3,8 +3,13 @@ import java.io.File; import java.io.FileWriter; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; import java.util.Properties; import java.util.jar.JarFile; +import java.util.stream.Stream; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecutionException; @@ -67,6 +72,7 @@ public void execute() throws MojoExecutionException { } } writeOutput(mappings); + validateHttpRoutingDependency(); } private void extractSliceManifest(File jarFile, String version, Properties mappings) throws IOException { @@ -119,4 +125,56 @@ private void writeOutput(Properties mappings) throws MojoExecutionException { throw new MojoExecutionException("Failed to write slice dependencies", e); } } + + private void validateHttpRoutingDependency() throws MojoExecutionException { + // Scan for routes.toml files in src/main/resources + var resourcesDir = new File(project.getBasedir(), "src/main/resources"); + if (!resourcesDir.exists()) { + return; + } + var routesTomlFiles = findRoutesTomlFiles(resourcesDir.toPath()); + if (routesTomlFiles.isEmpty()) { + return; + } + // Check if http-routing-adapter dependency exists + var hasRoutingAdapter = project.getArtifacts() + .stream() + .anyMatch(artifact -> "org.pragmatica-lite.aether".equals(artifact.getGroupId()) && + "http-routing-adapter".equals(artifact.getArtifactId())); + if (!hasRoutingAdapter) { + var sliceNames = routesTomlFiles.stream() + .map(path -> resourcesDir.toPath() + .relativize(path) + .toString()) + .toList(); + var message = String.format(""" + HTTP routing configured but dependency missing. + Found routes.toml in: %s + + Add to pom.xml: + + org.pragmatica-lite.aether + http-routing-adapter + ${aether.version} + provided + + """, + String.join(", ", sliceNames)); + throw new MojoExecutionException(message); + } + } + + private List findRoutesTomlFiles(Path resourcesDir) { + var routesTomlFiles = new ArrayList(); + try (Stream paths = Files.walk(resourcesDir)) { + paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName() + .toString() + .equals("routes.toml")) + .forEach(routesTomlFiles::add); + } catch (IOException e) { + getLog().debug("Error scanning resources directory: " + e.getMessage()); + } + return routesTomlFiles; + } } diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java index a91022c..64be57b 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java @@ -151,11 +151,6 @@ public Result> generate(SliceModel model, if (!routeConfig.hasRoutes()) { return Result.success(Option.none()); } - // Validate that http-routing-adapter is on classpath - var validationResult = validateHttpRoutingDependency(); - if (validationResult.isFailure()) { - return validationResult.map(_ -> Option.none()); - } try{ var routesName = model.simpleName() + "Routes"; var qualifiedName = model.packageName() + "." + routesName; @@ -172,27 +167,6 @@ public Result> generate(SliceModel model, } } - private Result validateHttpRoutingDependency() { - try{ - Class.forName("org.pragmatica.aether.http.adapter.SliceRouterFactory"); - return Result.success(Unit.unit()); - } catch (ClassNotFoundException _) { - var message = """ - HTTP routing configured but dependency missing. - Add to pom.xml: - - org.pragmatica-lite.aether - http-routing-adapter - ${aether.version} - provided - - """; - messager.printMessage(Diagnostic.Kind.ERROR, message); - return Causes.cause("Missing http-routing-adapter dependency") - .result(); - } - } - private void generateRoutesClass(PrintWriter out, SliceModel model, RouteConfig routeConfig, From c7e4576e813b8ef737b2a8b3ce8000beefc27483 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Wed, 28 Jan 2026 22:35:54 +0100 Subject: [PATCH 27/46] fix(slice): generate infra dependency calls using factory methods Changed FactoryClassGenerator to call infra service factory methods (e.g., CacheService.cacheService()) instead of InfraStore.get(). Before (wrong): InfraStore.instance() .flatMap(store -> store.get(...)) .toResult(Causes.cause("not available")) - Only retrieves, fails if not registered After (correct): Promise.success(CacheService.cacheService()) - Factory method uses getOrCreate() internally - Creates instance if not present Also removed unused imports (InfraStore, VersionedInstance, Causes, Option) from generated code. Co-Authored-By: Claude Sonnet 4.5 --- .../generator/FactoryClassGenerator.java | 39 ++++--------------- 1 file changed, 7 insertions(+), 32 deletions(-) diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java index d16371b..92235fc 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java @@ -126,14 +126,6 @@ private void generateImports(PrintWriter out, out.println("import org.pragmatica.lang.Promise;"); out.println("import org.pragmatica.lang.Unit;"); out.println("import org.pragmatica.lang.type.TypeToken;"); - // Import InfraStore and related types if we have infrastructure dependencies - if (allDeps.stream() - .anyMatch(DependencyModel::isInfrastructure)) { - out.println("import org.pragmatica.aether.infra.InfraStore;"); - out.println("import org.pragmatica.aether.infra.VersionedInstance;"); - out.println("import org.pragmatica.lang.utils.Causes;"); - out.println("import org.pragmatica.lang.Option;"); - } // Aspect-related imports if (model.hasAspects()) { out.println("import org.pragmatica.aether.slice.SliceRuntime;"); @@ -701,32 +693,15 @@ private void generateMixedDependencyChain(PrintWriter out, private String generateInfraStoreCall(DependencyModel infra) { var interfaceName = infra.interfaceSimpleName(); - var artifactId = inferInfraArtifactId(infra); - var errorMsg = escapeJavaString(interfaceName + " not available in InfraStore"); - return "InfraStore.instance()\n" - + " .flatMap(store -> store.get(\"org.pragmatica.aether.infra:" + artifactId - + "\", " + interfaceName + ".class)\n" - + " .stream()\n" - + " .findFirst()\n" - + " .map(vi -> Option.some(vi.instance()))\n" - + " .orElse(Option.none()))\n" - + " .toResult(Causes.cause(\"" + errorMsg + "\"))\n" - + " .async()"; + var factoryMethodName = toFactoryMethodName(interfaceName); + return "Promise.success(" + interfaceName + "." + factoryMethodName + "())"; } - private String inferInfraArtifactId(DependencyModel infra) { - // Extract artifact ID from package name - // org.pragmatica.aether.infra.cache.CacheService -> "cache" - // org.pragmatica.aether.infra.database.DatabaseService -> "database" - var pkg = infra.interfacePackage(); - var parts = pkg.split("\\."); - if (parts.length >= 5 && parts[3].equals("infra")) { - return parts[4]; - } - // Fallback: lowercase interface name without "Service" - return infra.interfaceSimpleName() - .replace("Service", "") - .toLowerCase(); + private String toFactoryMethodName(String className) { + if (className == null || className.isEmpty()) { + return className; + } + return Character.toLowerCase(className.charAt(0)) + className.substring(1); } private void generateDependencyInstantiation(PrintWriter out, From d0e40a371153c8657bd78df442e248cbfc0d3e10 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 29 Jan 2026 00:08:45 +0100 Subject: [PATCH 28/46] fix(slice): transform factory bytecode to replace UNRESOLVED versions with actual versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PackageSlicesMojo: implement bytecode transformation using JEP 484 Class-File API - buildVersionMap(): parse dependency file [slices] section to build artifact→version map - transformFactoryBytecode(): replace UNRESOLVED string constants in constant pool - stripSemverPrefix(): remove ^/~ prefixes to get actual versions (^1.0.0 → 1.0.0) - addClassFiles(): apply transformation to factory classes before JAR packaging Fixes runtime errors when loading same-module slice dependencies: - Version.version() failed to parse "UNRESOLVED" strings in factory bytecode - Now uses actual versions (1.0.0) instead of semver ranges (^1.0.0) feat(slice): add tinylog test logging to slice project template - SliceProjectInitializer: add tinylog-api and tinylog-impl dependencies (2.7.0) in test scope - Create tinylog.properties in src/test/resources with console output configuration - Default level: info, with commented examples for package-specific logging Co-Authored-By: Claude Sonnet 4.5 --- CHANGELOG.md | 5 + .../jbct/init/SliceProjectInitializer.java | 45 ++++++ .../jbct/maven/PackageSlicesMojo.java | 145 ++++++++++++++++-- 3 files changed, 186 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de39283..955cd65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ### Added - Init: groupId validation for `jbct init -g` parameter (validates Java package name format) +### Changed +- Slice init: added tinylog dependencies (2.7.0) in test scope +- Slice init: added tinylog.properties configuration file in test resources + ### Fixed - RFC-0004 compliance: removed non-standard slice-api.properties generation - RFC-0004 compliance: slice manifests now include `slice.interface` property @@ -20,6 +24,7 @@ - FactoryClassGenerator: infrastructure deps (CacheService, etc.) now use InfraStore.instance().get() - FactoryClassGenerator: only slice dependencies are proxied via SliceInvokerFacade - FactoryClassGenerator: reduced flatMap chain depth (e.g., 13→3 for UrlShortener with mixed deps) +- PackageSlicesMojo: bytecode transformation replaces UNRESOLVED versions with actual versions (strips semver prefix ^/~) ### Changed - Build: bump Aether to 0.8.1 diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java index 5c14536..168021a 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java @@ -118,10 +118,12 @@ private Result createDirectories() { try{ var srcMainJava = projectDir.resolve("src/main/java"); var srcTestJava = projectDir.resolve("src/test/java"); + var srcTestResources = projectDir.resolve("src/test/resources"); var metaInfDeps = projectDir.resolve("src/main/resources/META-INF/dependencies"); var slicesDir = projectDir.resolve("src/main/resources/slices"); Files.createDirectories(srcMainJava); Files.createDirectories(srcTestJava); + Files.createDirectories(srcTestResources); Files.createDirectories(metaInfDeps); Files.createDirectories(slicesDir); var packagePath = basePackage.replace(".", "/"); @@ -138,6 +140,7 @@ private Result> createAllFiles() { // Fork-Join: Create all independent file groups in parallel return Result.allOf(createProjectFiles(), createSourceFiles(), + createTestResources(), createDeployScripts(), createSliceConfigFiles()) .flatMap(fileLists -> createDependencyManifest() @@ -185,6 +188,11 @@ private Result> createSourceFiles() { .resolve(sliceName + "Test.java"))); } + private Result> createTestResources() { + var srcTestResources = projectDir.resolve("src/test/resources"); + return createFile("tinylog.properties.template", srcTestResources.resolve("tinylog.properties")).map(path -> List.of(path)); + } + private Result> createDeployScripts() { // Fork-Join: Create deploy and utility scripts in parallel return Result.allOf(createFile("deploy-forge.sh.template", @@ -253,6 +261,7 @@ private Option getInlineTemplate(String templateName) { case "CLAUDE.md" -> CLAUDE_MD_TEMPLATE; case "Slice.java.template" -> SLICE_INTERFACE_TEMPLATE; case "SliceTest.java.template" -> SLICE_TEST_TEMPLATE; + case "tinylog.properties.template" -> TINYLOG_PROPERTIES_TEMPLATE; case "deploy-forge.sh.template" -> DEPLOY_FORGE_TEMPLATE; case "deploy-test.sh.template" -> DEPLOY_TEST_TEMPLATE; case "deploy-prod.sh.template" -> DEPLOY_PROD_TEMPLATE; @@ -379,6 +388,18 @@ private static void makeExecutable(Path path) { 3.26.3 test + + org.tinylog + tinylog-api + 2.7.0 + test + + + org.tinylog + tinylog-impl + 2.7.0 + test + @@ -736,6 +757,30 @@ void should_process_request() { fi """; + private static final String TINYLOG_PROPERTIES_TEMPLATE = """ + # Tinylog configuration for test logging + # Documentation: https://tinylog.org/v2/configuration/ + + # Global logging level (trace, debug, info, warn, error) + level = info + + # Writer configuration - output to console with colored output + writer = console + writer.format = {date: HH:mm:ss.SSS} [{thread}] {class}.{method}() {level}: {message} + writer.stream = out + + # Package-specific logging levels + # Uncomment and adjust as needed: + # level@{{basePackage}} = debug + + # Show SQL queries (if using JDBC): + # level@org.pragmatica.jdbc = debug + + # Reduce noise from libraries: + # level@org.apache.http = warn + # level@io.netty = warn + """; + public Path projectDir() { return projectDir; } diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java index 13adeca..08d678b 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java @@ -5,11 +5,18 @@ import java.io.File; import java.io.FileWriter; import java.io.IOException; +import java.lang.classfile.ClassFile; +import java.lang.classfile.ClassModel; +import java.lang.classfile.CodeModel; +import java.lang.classfile.MethodModel; +import java.lang.classfile.instruction.ConstantInstruction; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Enumeration; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.jar.JarEntry; import java.util.jar.JarFile; @@ -257,16 +264,19 @@ private void createImplJar(SliceManifest manifest, DependencyClassification clas try{ var archiver = new JarArchiver(); archiver.setDestFile(jarFile); - // Add impl classes (includes request/response types) + // Generate dependency file content + var depsContent = generateDependencyFile(manifest, classification); + // Build version map from dependency file for bytecode transformation + var versionMap = buildVersionMap(depsContent); + // Add impl classes (includes request/response types) with bytecode transformation for (var className : manifest.allImplClasses()) { - addClassFiles(archiver, className); + addClassFiles(archiver, className, versionMap); } // Add application shared code addSharedCode(archiver, manifest); // Bundle external libs into fat JAR bundleExternalLibs(archiver, classification.externalDeps()); - // Generate and add dependency file - var depsContent = generateDependencyFile(manifest, classification); + // Add dependency file addDependencyFile(archiver, manifest, depsContent); var mavenArchiver = new MavenArchiver(); mavenArchiver.setArchiver(archiver); @@ -425,14 +435,131 @@ private void addDependencyFile(JarArchiver archiver, SliceManifest manifest, Str archiver.addFile(tempFile.toFile(), "META-INF/dependencies/" + factoryClassName); } - private void addClassFiles(JarArchiver archiver, String className) { + /** + * Builds artifact → version mapping from dependency file. + * Maps "groupId:artifactId" → "1.0.0" (strips semver range prefix) + */ + private Map buildVersionMap(String depsContent) { + var map = new HashMap(); + if (depsContent == null || depsContent.isEmpty()) { + return map; + } + var lines = depsContent.split("\n"); + boolean inSlicesSection = false; + for (var line : lines) { + var trimmed = line.trim(); + if (trimmed.equals("[slices]")) { + inSlicesSection = true; + continue; + } + if (trimmed.startsWith("[")) { + inSlicesSection = false; + } + if (inSlicesSection && !trimmed.isEmpty() && !trimmed.startsWith("#")) { + // Parse: org.example:artifact-name:^1.0.0 + var parts = trimmed.split(":"); + if (parts.length == 3) { + var artifact = parts[0] + ":" + parts[1]; + var version = stripSemverPrefix(parts[2]); + map.put(artifact, version); + } + } + } + return map; + } + + /** + * Strip semver range prefix (^, ~) to get actual version. + * ^1.0.0 → 1.0.0, ~2.1.0 → 2.1.0, 1.0.0 → 1.0.0 + */ + private String stripSemverPrefix(String version) { + if (version.startsWith("^") || version.startsWith("~")) { + return version.substring(1); + } + return version; + } + + /** + * Transforms factory .class file to replace UNRESOLVED version strings in constant pool. + * Uses JEP 484 Class-File API for bytecode manipulation. + */ + private byte[] transformFactoryBytecode(File classFile, Map versionMap) + throws IOException { + var originalBytes = Files.readAllBytes(classFile.toPath()); + // Skip transformation if version map is empty + if (versionMap.isEmpty()) { + return originalBytes; + } + var cf = ClassFile.of(); + var classModel = cf.parse(originalBytes); + return cf.transformClass(classModel, + (classBuilder, classElement) -> { + if (classElement instanceof MethodModel methodModel) { + classBuilder.transformMethod(methodModel, + (methodBuilder, methodElement) -> { + if (methodElement instanceof CodeModel codeModel) { + methodBuilder.transformCode(codeModel, + (codeBuilder, codeElement) -> { + // Match LoadConstantInstruction (ldc) loading string constants + if (codeElement instanceof ConstantInstruction.LoadConstantInstruction ldc && + ldc.constantValue() instanceof String str) { + // Check if this is an UNRESOLVED artifact string + if (str.contains(":UNRESOLVED")) { + // Extract artifact without version: "groupId:artifactId:UNRESOLVED" + var lastColonIdx = str.lastIndexOf(":UNRESOLVED"); + if (lastColonIdx > 0) { + var artifact = str.substring(0, + lastColonIdx); + var version = versionMap.get(artifact); + if (version != null) { + // Replace with resolved version + codeBuilder.loadConstant(artifact + ":" + version); + getLog().debug("Transformed: " + str + + " → " + artifact + + ":" + version); + return; + } + } + } + } + // Keep original instruction if no match + codeBuilder.with(codeElement); + }); + } else { + methodBuilder.with(methodElement); + } + }); + } else { + classBuilder.with(classElement); + } + }); + } + + private void addClassFiles(JarArchiver archiver, String className, Map versionMap) + throws MojoExecutionException { var classesPath = classesDirectory.toPath(); var paths = SliceManifest.classToPathsWithInner(className, classesPath); - for (var relativePath : paths) { - var classFile = new File(classesDirectory, relativePath); - if (classFile.exists()) { - archiver.addFile(classFile, relativePath); + try{ + for (var relativePath : paths) { + var classFile = new File(classesDirectory, relativePath); + if (classFile.exists()) { + // Transform factory classes with UNRESOLVED versions + if (className.endsWith("Factory") && !versionMap.isEmpty() && + relativePath.equals(className.replace('.', '/') + ".class")) { + var transformedBytes = transformFactoryBytecode(classFile, versionMap); + // Write transformed bytecode to temp file for archiving + var tempClass = Files.createTempFile("factory-", ".class"); + Files.write(tempClass, transformedBytes); + archiver.addFile(tempClass.toFile(), relativePath); + getLog().info("Transformed bytecode: " + className); + } else { + // Add non-factory classes and inner classes as-is + archiver.addFile(classFile, relativePath); + } + } } + } catch (IOException e) { + throw new MojoExecutionException("Failed to transform factory bytecode", e); } } From d831e34b8d696a980af671855421889c5509cef7 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 29 Jan 2026 14:26:51 +0100 Subject: [PATCH 29/46] chore: update changelog date to 2026-01-29 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 955cd65..e70e643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [0.6.0] - 2026-01-27 +## [0.6.0] - 2026-01-29 ### Added - Init: groupId validation for `jbct init -g` parameter (validates Java package name format) From 32e67aaacf604928dffc65440d03631d5df20d58 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 29 Jan 2026 15:16:19 +0100 Subject: [PATCH 30/46] refactor: improve code quality in bytecode transformer and version resolver - Extract nested lambdas in PackageSlicesMojo.transformFactoryBytecode() into separate methods - Replace fold() with or() in GitHubVersionResolver for cleaner fallback handling - Replace fold() with recover() for error handling with default values - Extract timeout magic numbers to named constants (API_TIMEOUT) --- .../jbct/init/GitHubVersionResolver.java | 15 +-- .../jbct/upgrade/GitHubReleaseChecker.java | 3 +- .../jbct/maven/PackageSlicesMojo.java | 96 +++++++++++-------- 3 files changed, 65 insertions(+), 49 deletions(-) diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java index 52e14ec..0fdb80f 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java @@ -37,6 +37,7 @@ public final class GitHubVersionResolver { // 24 hours private static final String GITHUB_API_BASE = "https://api.github.com/repos"; private static final Pattern TAG_PATTERN = Pattern.compile("\"tag_name\"\\s*:\\s*\"v?([^\"]+)\""); + private static final Duration API_TIMEOUT = Duration.ofSeconds(10); // Default fallback versions when offline or API fails private static final String DEFAULT_PRAGMATICA_VERSION = "0.11.1"; @@ -110,11 +111,11 @@ private static String maxVersion(String v1, String v2) { var comparison = Number.parseInt(parts1[index]) .flatMap(num1 -> Number.parseInt(parts2[index]) .map(num2 -> Integer.compare(num1, num2))); - var cmp = comparison.fold(_ -> { - LOG.debug("Failed to parse version numbers, using v1: {} vs v2: {}", v1, v2); - return 0; - }, - value -> value); + var cmp = comparison.recover(cause -> { + LOG.debug("Failed to parse version numbers, using v1: {} vs v2: {}", v1, v2); + return 0; + }) + .unwrap(); if (cmp > 0) { return v1; } @@ -146,7 +147,7 @@ private String getVersion(String owner, String repo, String defaultVersion) { } // Fetch from GitHub return fetchLatestVersion(owner, repo).onSuccess(version -> updateCache(cacheKey, timestampKey, version)) - .fold(_ -> defaultVersion, version -> version); + .or(defaultVersion); } private void updateCache(String cacheKey, String timestampKey, String version) { @@ -162,7 +163,7 @@ private Result fetchLatestVersion(String owner, String repo) { .uri(URI.create(url)) .header("Accept", "application/vnd.github.v3+json") .header("User-Agent", "jbct-cli") - .timeout(Duration.ofSeconds(10)) + .timeout(API_TIMEOUT) .GET() .build(); return http.sendString(request) diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/upgrade/GitHubReleaseChecker.java b/jbct-core/src/main/java/org/pragmatica/jbct/upgrade/GitHubReleaseChecker.java index 4845c69..8763202 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/upgrade/GitHubReleaseChecker.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/upgrade/GitHubReleaseChecker.java @@ -18,6 +18,7 @@ public final class GitHubReleaseChecker { private static final String GITHUB_API_URL = "https://api.github.com/repos/siy/jbct-cli/releases/latest"; private static final Pattern VERSION_PATTERN = Pattern.compile("\"tag_name\"\\s*:\\s*\"v?([^\"]+)\""); private static final Pattern ASSET_URL_PATTERN = Pattern.compile("\"browser_download_url\"\\s*:\\s*\"([^\"]+jbct[^\"]*\\.jar)\""); + private static final Duration API_TIMEOUT = Duration.ofSeconds(30); private final HttpOperations http; private final String apiUrl; @@ -51,7 +52,7 @@ public Result checkLatestRelease() { .uri(URI.create(apiUrl)) .header("Accept", "application/vnd.github.v3+json") .header("User-Agent", "jbct-cli") - .timeout(Duration.ofSeconds(30)) + .timeout(API_TIMEOUT) .GET() .build(); return http.sendString(request) diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java index 08d678b..bdf14cd 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java @@ -9,6 +9,12 @@ import java.lang.classfile.ClassModel; import java.lang.classfile.CodeModel; import java.lang.classfile.MethodModel; +import java.lang.classfile.ClassElement; +import java.lang.classfile.MethodElement; +import java.lang.classfile.CodeElement; +import java.lang.classfile.ClassBuilder; +import java.lang.classfile.MethodBuilder; +import java.lang.classfile.CodeBuilder; import java.lang.classfile.instruction.ConstantInstruction; import java.nio.file.Files; import java.nio.file.Path; @@ -486,53 +492,61 @@ private String stripSemverPrefix(String version) { private byte[] transformFactoryBytecode(File classFile, Map versionMap) throws IOException { var originalBytes = Files.readAllBytes(classFile.toPath()); - // Skip transformation if version map is empty if (versionMap.isEmpty()) { return originalBytes; } var cf = ClassFile.of(); var classModel = cf.parse(originalBytes); - return cf.transformClass(classModel, - (classBuilder, classElement) -> { - if (classElement instanceof MethodModel methodModel) { - classBuilder.transformMethod(methodModel, - (methodBuilder, methodElement) -> { - if (methodElement instanceof CodeModel codeModel) { - methodBuilder.transformCode(codeModel, - (codeBuilder, codeElement) -> { - // Match LoadConstantInstruction (ldc) loading string constants - if (codeElement instanceof ConstantInstruction.LoadConstantInstruction ldc && - ldc.constantValue() instanceof String str) { - // Check if this is an UNRESOLVED artifact string + return cf.transformClass(classModel, (builder, element) -> transformClassElement(builder, element, versionMap)); + } + + private void transformClassElement(ClassBuilder builder, ClassElement element, Map versionMap) { + if (element instanceof MethodModel methodModel) { + builder.transformMethod(methodModel, + (methodBuilder, methodElement) -> transformMethodElement(methodBuilder, + methodElement, + versionMap)); + } else { + builder.with(element); + } + } + + private void transformMethodElement(MethodBuilder builder, MethodElement element, Map versionMap) { + if (element instanceof CodeModel codeModel) { + builder.transformCode(codeModel, + (codeBuilder, codeElement) -> transformCodeElement(codeBuilder, + codeElement, + versionMap)); + } else { + builder.with(element); + } + } + + private void transformCodeElement(CodeBuilder builder, CodeElement element, Map versionMap) { + if (element instanceof ConstantInstruction.LoadConstantInstruction ldc && ldc.constantValue() instanceof String str) { + replaceUnresolvedConstant(builder, element, str, versionMap); + } else { + builder.with(element); + } + } + + private void replaceUnresolvedConstant(CodeBuilder builder, + CodeElement element, + String str, + Map versionMap) { if (str.contains(":UNRESOLVED")) { - // Extract artifact without version: "groupId:artifactId:UNRESOLVED" - var lastColonIdx = str.lastIndexOf(":UNRESOLVED"); - if (lastColonIdx > 0) { - var artifact = str.substring(0, - lastColonIdx); - var version = versionMap.get(artifact); - if (version != null) { - // Replace with resolved version - codeBuilder.loadConstant(artifact + ":" + version); - getLog().debug("Transformed: " + str - + " → " + artifact - + ":" + version); - return; - } - } - } - } - // Keep original instruction if no match - codeBuilder.with(codeElement); - }); - } else { - methodBuilder.with(methodElement); - } - }); - } else { - classBuilder.with(classElement); - } - }); + var lastColonIdx = str.lastIndexOf(":UNRESOLVED"); + if (lastColonIdx > 0) { + var artifact = str.substring(0, lastColonIdx); + var version = versionMap.get(artifact); + if (version != null) { + builder.loadConstant(artifact + ":" + version); + getLog().debug("Transformed: " + str + " → " + artifact + ":" + version); + return; + } + } + } + builder.with(element); } private void addClassFiles(JarArchiver archiver, String className, Map versionMap) From df310214ee042b4b478fbcc57d1cc58e389c9ab0 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 29 Jan 2026 15:18:18 +0100 Subject: [PATCH 31/46] test: add unit tests for PackageSlicesMojo - Test buildVersionMap() with slices dependencies, empty content, null content, no slices section - Test stripSemverPrefix() for caret, tilde, and no prefix cases - All 7 tests passing --- .../jbct/maven/PackageSlicesMojoTest.java | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 jbct-maven-plugin/src/test/java/org/pragmatica/jbct/maven/PackageSlicesMojoTest.java diff --git a/jbct-maven-plugin/src/test/java/org/pragmatica/jbct/maven/PackageSlicesMojoTest.java b/jbct-maven-plugin/src/test/java/org/pragmatica/jbct/maven/PackageSlicesMojoTest.java new file mode 100644 index 0000000..7d4ee04 --- /dev/null +++ b/jbct-maven-plugin/src/test/java/org/pragmatica/jbct/maven/PackageSlicesMojoTest.java @@ -0,0 +1,89 @@ +package org.pragmatica.jbct.maven; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for PackageSlicesMojo version mapping and transformation logic. + */ +class PackageSlicesMojoTest { + @Test + void buildVersionMap_parsesSlicesDependencies() { + var depsContent = """ + [slices] + org.example:slice-analytics:^1.0.0 + org.example:slice-inventory:^2.1.0 + org.example:slice-orders:~3.0.0 + + [infrastructure] + org.pragmatica-lite:core:0.9.10 + """; + var mojo = new PackageSlicesMojo(); + Map map = invokePrivate(mojo, "buildVersionMap", String.class, depsContent); + assertEquals("1.0.0", map.get("org.example:slice-analytics")); + assertEquals("2.1.0", map.get("org.example:slice-inventory")); + assertEquals("3.0.0", map.get("org.example:slice-orders")); + assertNull(map.get("org.pragmatica-lite:core")); + } + + @Test + void buildVersionMap_handlesEmptyContent() { + var mojo = new PackageSlicesMojo(); + Map map = invokePrivate(mojo, "buildVersionMap", String.class, ""); + assertTrue(map.isEmpty()); + } + + @Test + void buildVersionMap_handlesNullContent() { + var mojo = new PackageSlicesMojo(); + Map map = invokePrivate(mojo, "buildVersionMap", String.class, (String) null); + assertTrue(map.isEmpty()); + } + + @Test + void buildVersionMap_handlesNoSlicesSection() { + var depsContent = """ + [infrastructure] + org.pragmatica-lite:core:0.9.10 + """; + var mojo = new PackageSlicesMojo(); + Map map = invokePrivate(mojo, "buildVersionMap", String.class, depsContent); + assertTrue(map.isEmpty()); + } + + @Test + void stripSemverPrefix_removesCaretPrefix() { + var mojo = new PackageSlicesMojo(); + String result = invokePrivate(mojo, "stripSemverPrefix", String.class, "^1.2.3"); + assertEquals("1.2.3", result); + } + + @Test + void stripSemverPrefix_removesTildePrefix() { + var mojo = new PackageSlicesMojo(); + String result = invokePrivate(mojo, "stripSemverPrefix", String.class, "~1.2.3"); + assertEquals("1.2.3", result); + } + + @Test + void stripSemverPrefix_preservesVersionWithoutPrefix() { + var mojo = new PackageSlicesMojo(); + String result = invokePrivate(mojo, "stripSemverPrefix", String.class, "1.2.3"); + assertEquals("1.2.3", result); + } + + @SuppressWarnings("unchecked") + private T invokePrivate(Object target, String methodName, Class paramType, Object arg) { + try{ + var method = target.getClass() + .getDeclaredMethod(methodName, paramType); + method.setAccessible(true); + return (T) method.invoke(target, arg); + } catch (Exception e) { + throw new RuntimeException("Failed to invoke " + methodName, e); + } + } +} From 084f664f051869d7fa9a207e5592e248e5d91543 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Thu, 29 Jan 2026 16:05:32 +0100 Subject: [PATCH 32/46] refactor: use Result.all() with or() for version comparison Replace flatMap chain + recover().unwrap() with cleaner Result.all() pattern: - Aggregates both parseInt operations - Maps to Integer.compare() - Uses .or(0) for fallback value More idiomatic JBCT pattern. --- .../pragmatica/jbct/init/GitHubVersionResolver.java | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java index 0fdb80f..05e4f0f 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java @@ -107,15 +107,10 @@ private static String maxVersion(String v1, String v2) { var parts2 = v2.split("\\."); for (int i = 0; i < Math.min(parts1.length, parts2.length); i++) { final var index = i; - // Make effectively final for lambda - var comparison = Number.parseInt(parts1[index]) - .flatMap(num1 -> Number.parseInt(parts2[index]) - .map(num2 -> Integer.compare(num1, num2))); - var cmp = comparison.recover(cause -> { - LOG.debug("Failed to parse version numbers, using v1: {} vs v2: {}", v1, v2); - return 0; - }) - .unwrap(); + var cmp = Result.all(Number.parseInt(parts1[index]), + Number.parseInt(parts2[index])) + .map((num1, num2) -> Integer.compare(num1, num2)) + .or(0); if (cmp > 0) { return v1; } From b0529989443e1f99dd280f3e356d6d3e42bd2eb4 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Sat, 31 Jan 2026 14:42:04 +0100 Subject: [PATCH 33/46] fix(slice): include Routes class and service file in slice JARs - ManifestGenerator: accept optional Routes class in impl.classes - SliceProcessor: sequential generation to pass Routes class to manifest - PackageSlicesMojo: filter service file per-slice for multi-slice modules Fixes ServiceLoader discovery of SliceRouterFactory implementations --- .../jbct/maven/PackageSlicesMojo.java | 27 ++++++++++++++ .../pragmatica/jbct/slice/SliceProcessor.java | 35 +++++++++---------- .../slice/generator/ManifestGenerator.java | 15 ++++++-- 3 files changed, 57 insertions(+), 20 deletions(-) diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java index bdf14cd..3c43def 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java @@ -284,6 +284,8 @@ private void createImplJar(SliceManifest manifest, DependencyClassification clas bundleExternalLibs(archiver, classification.externalDeps()); // Add dependency file addDependencyFile(archiver, manifest, depsContent); + // Add filtered service file for SliceRouterFactory + addServiceFile(archiver, manifest); var mavenArchiver = new MavenArchiver(); mavenArchiver.setArchiver(archiver); mavenArchiver.setOutputFile(jarFile); @@ -441,6 +443,31 @@ private void addDependencyFile(JarArchiver archiver, SliceManifest manifest, Str archiver.addFile(tempFile.toFile(), "META-INF/dependencies/" + factoryClassName); } + private void addServiceFile(JarArchiver archiver, SliceManifest manifest) + throws MojoExecutionException { + var serviceFile = new File(classesDirectory, + "META-INF/services/org.pragmatica.aether.http.adapter.SliceRouterFactory"); + if (!serviceFile.exists()) { + return; + } + try { + var routesClass = manifest.slicePackage() + "." + manifest.sliceName() + "Routes"; + var lines = Files.readAllLines(serviceFile.toPath()); + var filteredLines = lines.stream() + .filter(line -> line.trim().equals(routesClass)) + .toList(); + if (!filteredLines.isEmpty()) { + var tempService = Files.createTempFile("service-", ".txt"); + Files.writeString(tempService, String.join("\n", filteredLines)); + archiver.addFile(tempService.toFile(), + "META-INF/services/org.pragmatica.aether.http.adapter.SliceRouterFactory"); + getLog().debug("Added service file entry for: " + routesClass); + } + } catch (IOException e) { + throw new MojoExecutionException("Failed to add service file", e); + } + } + /** * Builds artifact → version mapping from dependency file. * Maps "groupId:artifactId" → "1.0.0" (strips semver range prefix) diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java index 112cf6b..1bf5c58 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java @@ -128,12 +128,10 @@ private void processSliceInterface(TypeElement interfaceElement) { } private void generateArtifacts(TypeElement interfaceElement, SliceModel sliceModel) { - Result.all(generateFactory(interfaceElement, sliceModel), - generateSliceManifest(interfaceElement, sliceModel), - generateRoutes(interfaceElement, sliceModel)) - .map((_, _, _) -> Unit.unit()) - .onFailure(cause -> error(interfaceElement, - cause.message())); + generateFactory(interfaceElement, sliceModel) + .flatMap(_ -> generateRoutes(interfaceElement, sliceModel)) + .flatMap(routesClassOpt -> generateSliceManifest(interfaceElement, sliceModel, routesClassOpt)) + .onFailure(cause -> error(interfaceElement, cause.message())); } private Result generateFactory(TypeElement interfaceElement, SliceModel sliceModel) { @@ -142,18 +140,21 @@ private Result generateFactory(TypeElement interfaceElement, SliceModel sl "Generated factory: " + sliceModel.simpleName() + "Factory")); } - private Result generateSliceManifest(TypeElement interfaceElement, SliceModel sliceModel) { - return manifestGenerator.generateSliceManifest(sliceModel) + private Result generateSliceManifest(TypeElement interfaceElement, + SliceModel sliceModel, + Option routesClass) { + return manifestGenerator.generateSliceManifest(sliceModel, routesClass) .onSuccess(_ -> note(interfaceElement, "Generated slice manifest: META-INF/slice/" + sliceModel.simpleName() + ".manifest")); } - private Result generateRoutes(TypeElement interfaceElement, SliceModel sliceModel) { + private Result> generateRoutes(TypeElement interfaceElement, SliceModel sliceModel) { var packageName = sliceModel.packageName(); return loadRouteConfig(packageName) - .flatMap(configOpt -> configOpt.fold(() -> Result.success(Unit.unit()), - config -> generateRoutesFromConfig(interfaceElement, sliceModel, config))); + .flatMap(configOpt -> configOpt.fold( + () -> Result.success(Option.none()), + config -> generateRoutesFromConfig(interfaceElement, sliceModel, config))); } private Result> loadRouteConfig(String packageName) { @@ -176,19 +177,17 @@ private Result> loadRouteConfig(String packageName) { } } - private Result generateRoutesFromConfig(TypeElement interfaceElement, - SliceModel sliceModel, - RouteConfig config) { + private Result> generateRoutesFromConfig(TypeElement interfaceElement, + SliceModel sliceModel, + RouteConfig config) { var packageName = sliceModel.packageName(); - return errorDiscovery.discover(packageName, - config.errors()) + return errorDiscovery.discover(packageName, config.errors()) .flatMap(errorMappings -> routeGenerator.generate(sliceModel, config, errorMappings)) .onSuccess(qualifiedNameOpt -> { qualifiedNameOpt.onPresent(routeServiceEntries::add); note(interfaceElement, "Generated routes: " + sliceModel.simpleName() + "Routes"); - }) - .map(_ -> Unit.unit()); + }); } private void error(Element element, String message) { diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/ManifestGenerator.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/ManifestGenerator.java index dd86079..f203e18 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/ManifestGenerator.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/ManifestGenerator.java @@ -3,6 +3,7 @@ import org.pragmatica.jbct.slice.model.DependencyModel; import org.pragmatica.jbct.slice.model.MethodModel; import org.pragmatica.jbct.slice.model.SliceModel; +import org.pragmatica.lang.Option; import org.pragmatica.lang.Result; import org.pragmatica.lang.Unit; import org.pragmatica.lang.utils.Causes; @@ -55,6 +56,14 @@ private String getProcessorVersion() { * Written to META-INF/slice/{SliceName}.manifest */ public Result generateSliceManifest(SliceModel model) { + return generateSliceManifest(model, Option.none()); + } + + /** + * Generate per-slice manifest with optional Routes class. + * Written to META-INF/slice/{SliceName}.manifest + */ + public Result generateSliceManifest(SliceModel model, Option routesClass) { try{ var props = new Properties(); var sliceName = model.simpleName(); @@ -68,7 +77,7 @@ public Result generateSliceManifest(SliceModel model) { props.setProperty("slice.artifactSuffix", toKebabCase(sliceName)); props.setProperty("slice.package", model.packageName()); // Implementation classes - var implClasses = collectImplClasses(model); + var implClasses = collectImplClasses(model, routesClass); props.setProperty("impl.classes", String.join(",", implClasses)); // Request/Response types from methods var requestTypes = collectRequestTypes(model); @@ -119,7 +128,7 @@ public Result generateSliceManifest(SliceModel model) { } } - private List collectImplClasses(SliceModel model) { + private List collectImplClasses(SliceModel model, Option routesClass) { var classes = new ArrayList(); // Original @Slice interface classes.add(model.qualifiedName()); @@ -134,6 +143,8 @@ private List collectImplClasses(SliceModel model) { for (var dep : model.dependencies()) { classes.add(model.packageName() + "." + model.simpleName() + "Factory$" + dep.localRecordName()); } + // Add Routes class if generated + routesClass.onPresent(classes::add); return classes; } From bedcef83bcef4a40cc1201679d1d055ebfdcfae1 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Sat, 31 Jan 2026 14:42:08 +0100 Subject: [PATCH 34/46] feat(slice): remove unused blueprint properties, default to 3 instances - Removed unused properties: timeoutMs, memoryMb, loadBalancing, affinityKey - Aether runtime only consumes artifact + instances (per SliceSpec) - Changed default from 1 to 3 instances - Updated docs to reflect actual implementation --- docs/slice/deployment.md | 18 ++--------- docs/slice/development-guide.md | 14 ++------- docs/slice/reference.md | 30 ++++--------------- .../jbct/init/SliceProjectInitializer.java | 24 ++------------- .../pragmatica/jbct/slice/SliceConfig.java | 24 ++++----------- .../jbct/maven/GenerateBlueprintMojo.java | 25 ---------------- 6 files changed, 19 insertions(+), 116 deletions(-) diff --git a/docs/slice/deployment.md b/docs/slice/deployment.md index 2a1b8f5..384b43f 100644 --- a/docs/slice/deployment.md +++ b/docs/slice/deployment.md @@ -58,14 +58,10 @@ instances = 1 [[slices]] artifact = "org.example:commerce-payment-service:1.0.0" instances = 2 -timeout_ms = 30000 -load_balancing = "round_robin" [[slices]] artifact = "org.example:commerce-order-service:1.0.0" instances = 3 -memory_mb = 512 -affinity_key = "customerId" ``` ### Slice Configuration @@ -76,22 +72,14 @@ Blueprint properties are read from per-slice config files at `src/main/resources # src/main/resources/slices/OrderService.toml [blueprint] -instances = 3 -timeout_ms = 30000 -memory_mb = 512 -load_balancing = "round_robin" -affinity_key = "customerId" +instances = 5 ``` | Property | Type | Default | Description | |----------|------|---------|-------------| -| `instances` | int | `1` | Number of slice instances | -| `timeout_ms` | int | - | Request timeout in milliseconds | -| `memory_mb` | int | - | Memory allocation per instance | -| `load_balancing` | string | - | Load balancing strategy (`round_robin`, `least_connections`) | -| `affinity_key` | string | - | Request field for sticky routing | +| `instances` | int | `3` | Number of slice instances | -If a slice config file is missing, default values are used (logged as info message). +If a slice config file is missing, default value is used (`instances = 3`, logged as info message). ### Topological Ordering diff --git a/docs/slice/development-guide.md b/docs/slice/development-guide.md index 70d17bb..109483c 100644 --- a/docs/slice/development-guide.md +++ b/docs/slice/development-guide.md @@ -343,26 +343,18 @@ Each slice can have a configuration file that controls runtime properties like i # src/main/resources/slices/OrderService.toml [blueprint] -instances = 3 -timeout_ms = 30000 -memory_mb = 512 -load_balancing = "round_robin" -affinity_key = "customerId" +instances = 5 ``` ### Available Properties | Property | Type | Default | Description | |----------|------|---------|-------------| -| `instances` | int | `1` | Number of slice instances | -| `timeout_ms` | int | - | Request timeout in milliseconds | -| `memory_mb` | int | - | Memory per instance | -| `load_balancing` | string | - | `round_robin` or `least_connections` | -| `affinity_key` | string | - | Request field for sticky routing | +| `instances` | int | `3` | Number of slice instances | ### When Config is Missing -If no config file exists, default values are used (logged as info). This is intentional - you don't need a config file for simple slices. +If no config file exists, default value is used (`instances = 3`, logged as info). You don't need a config file for simple slices. ## Build Workflow diff --git a/docs/slice/reference.md b/docs/slice/reference.md index 8bc55c2..5f4e2af 100644 --- a/docs/slice/reference.md +++ b/docs/slice/reference.md @@ -242,40 +242,20 @@ For a slice named `OrderService`, create `src/main/resources/slices/OrderService # Slice configuration for OrderService [blueprint] -# Required: number of slice instances -instances = 3 - -# Optional: request timeout in milliseconds -timeout_ms = 30000 - -# Optional: memory allocation per instance -memory_mb = 512 - -# Optional: load balancing strategy -load_balancing = "round_robin" - -# Optional: request field for sticky routing -affinity_key = "customerId" +# Number of slice instances (default: 3) +instances = 5 ``` ### Configuration Properties | Section | Property | Type | Default | Description | |---------|----------|------|---------|-------------| -| `[blueprint]` | `instances` | int | `1` | Number of slice instances | -| `[blueprint]` | `timeout_ms` | int | - | Request timeout | -| `[blueprint]` | `memory_mb` | int | - | Memory per instance | -| `[blueprint]` | `load_balancing` | string | - | Load balancing strategy | -| `[blueprint]` | `affinity_key` | string | - | Field for sticky routing | +| `[blueprint]` | `instances` | int | `3` | Number of slice instances | ### Default Behavior -If no config file exists for a slice, defaults are used: -- `instances = 1` -- No timeout (uses runtime default) -- No memory limit -- Default load balancing -- No affinity +If no config file exists for a slice, default value is used: +- `instances = 3` The plugin logs an info message when using defaults. diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java index 168021a..06d3950 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java @@ -712,28 +712,8 @@ void should_process_request() { # This file is read by the annotation processor and blueprint generator [blueprint] - # Number of instances to deploy - instances = 1 - - # Request timeout in milliseconds - # timeout_ms = 30000 - - # Memory allocation in MB - # memory_mb = 512 - - # Load balancing strategy: round_robin, least_connections, consistent_hash, random - # load_balancing = "round_robin" - - # For consistent_hash load balancing, specify the request field to hash on - # affinity_key = "customerId" - - # [transport] - # Transport configuration (future) - # type = "http" - - # [transport.http] - # HTTP-specific settings (future) - # port = 8080 + # Number of instances to deploy (default: 3) + instances = 3 """; private static final String GENERATE_BLUEPRINT_TEMPLATE = """ diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceConfig.java b/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceConfig.java index bf54806..243730c 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceConfig.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceConfig.java @@ -14,21 +14,13 @@ public record SliceConfig(BlueprintConfig blueprint) { /** * Blueprint-related configuration. */ - public record BlueprintConfig(int instances, - Option timeoutMs, - Option memoryMb, - Option loadBalancing, - Option affinityKey) { - public static BlueprintConfig blueprintConfig(int instances, - Option timeoutMs, - Option memoryMb, - Option loadBalancing, - Option affinityKey) { - return new BlueprintConfig(instances, timeoutMs, memoryMb, loadBalancing, affinityKey); + public record BlueprintConfig(int instances) { + public static BlueprintConfig blueprintConfig(int instances) { + return new BlueprintConfig(instances); } public static BlueprintConfig defaults() { - return blueprintConfig(1, Option.empty(), Option.empty(), Option.empty(), Option.empty()); + return blueprintConfig(3); } } @@ -49,12 +41,8 @@ public static Result load(Path configPath) { private static SliceConfig fromTomlDocument(org.pragmatica.config.toml.TomlDocument toml) { var instances = toml.getInt("blueprint", "instances") - .or(() -> 1); - var timeoutMs = toml.getInt("blueprint", "timeout_ms"); - var memoryMb = toml.getInt("blueprint", "memory_mb"); - var loadBalancing = toml.getString("blueprint", "load_balancing"); - var affinityKey = toml.getString("blueprint", "affinity_key"); - var blueprint = BlueprintConfig.blueprintConfig(instances, timeoutMs, memoryMb, loadBalancing, affinityKey); + .or(() -> 3); + var blueprint = BlueprintConfig.blueprintConfig(instances); return new SliceConfig(blueprint); } } diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/GenerateBlueprintMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/GenerateBlueprintMojo.java index 6e561a2..40e3f09 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/GenerateBlueprintMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/GenerateBlueprintMojo.java @@ -299,31 +299,6 @@ private void generateBlueprint() throws MojoExecutionException { .blueprint() .instances()) .append("\n"); - // Add optional properties if configured - entry.config() - .blueprint() - .timeoutMs() - .onPresent(timeout -> sb.append("timeout_ms = ") - .append(timeout) - .append("\n")); - entry.config() - .blueprint() - .memoryMb() - .onPresent(memory -> sb.append("memory_mb = ") - .append(memory) - .append("\n")); - entry.config() - .blueprint() - .loadBalancing() - .onPresent(lb -> sb.append("load_balancing = \"") - .append(lb) - .append("\"\n")); - entry.config() - .blueprint() - .affinityKey() - .onPresent(key -> sb.append("affinity_key = \"") - .append(key) - .append("\"\n")); if (entry.isDependency()) { sb.append("# transitive dependency\n"); } From 2f588c9384a51a7338986d10259f266ef9b320eb Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Sat, 31 Jan 2026 15:10:31 +0100 Subject: [PATCH 35/46] fix(slice): wrapper implements both Slice and slice interface Generated slice wrapper record now implements both Slice (for Aether runtime) and the slice interface (e.g., UrlShortener) with delegate methods. This fixes router type checking where sliceType().isInstance() was returning false. Also bumps pragmatica-lite to 0.11.2 and documents dependency cycle workaround in build commands. --- CLAUDE.md | 147 ++++++++++++++++-- docs/slice/reference.md | 2 +- .../jbct/init/GitHubVersionResolver.java | 2 +- .../jbct/maven/PackageSlicesMojo.java | 2 +- pom.xml | 2 +- .../docs/SLICE-FACTORY-GENERATION.md | 28 +++- .../generator/FactoryClassGenerator.java | 10 +- .../jbct/slice/SliceProcessorTest.java | 2 +- 8 files changed, 168 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f87e374..196a27f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,7 @@ jbct-cli/ jbct format # Format Java files jbct lint # Analyze for JBCT compliance jbct check # Format check + lint (for CI) +jbct score # Calculate JBCT compliance score (0-100) jbct upgrade # Update CLI from GitHub Releases jbct init [dir] # Create new JBCT project + install AI tools jbct init --slice [dir] # Create new Aether slice project @@ -68,11 +69,121 @@ JBCT-RET-01 = "error" JBCT-VO-01 = "warning" ``` +## JBCT Compliance Scoring + +The `jbct score` command calculates a 0-100 compliance score based on lint violations across 6 weighted categories. + +### Usage + +```bash +# CLI +jbct score # Terminal output with progress bars +jbct score --format json # JSON output +jbct score --format badge # SVG badge +jbct score --baseline 75 # Fail if score < 75 + +# Maven +mvn jbct:score +mvn jbct:score -Djbct.score.baseline=75 +``` + +### Scoring Formula + +**Category score:** +``` +weighted_violations = Σ(count[severity] × multiplier[severity]) + ERROR: × 2.5 + WARNING: × 1.0 + INFO: × 0.3 + +category_score = 100 × (1 - weighted_violations / checkpoints) +``` + +**Overall score:** +``` +overall_score = Σ(category_score[i] × weight[i]) +``` + +### Scoring Categories + +| Category | Weight | Rules Included | +|----------|--------|----------------| +| Return Types | 25% | JBCT-RET-01, JBCT-RET-02, JBCT-RET-03, JBCT-NEST-02, JBCT-RES-01 | +| Null Safety | 20% | JBCT-NULL-01, JBCT-NULL-02 | +| Exception Hygiene | 20% | JBCT-EXC-01, JBCT-EXC-02, JBCT-NAME-02 | +| Pattern Purity | 15% | JBCT-PAT-01, JBCT-NEST-01, JBCT-CHAIN-01, JBCT-IMPORT-01, JBCT-FQN-01, JBCT-DOMAIN-01, JBCT-LOOP-01, JBCT-LOG-01, JBCT-LOG-02 | +| Factory Methods | 10% | JBCT-VO-01, JBCT-VO-02, JBCT-VO-03, JBCT-VO-04, JBCT-ERR-01, JBCT-NAME-01, JBCT-PARSE-01 | +| Lambda Compliance | 10% | JBCT-LAM-01, JBCT-LAM-02, JBCT-LAM-03, JBCT-LAM-04, JBCT-STATIC-01 | + +### Output Formats + +**Terminal** (default): +``` +╔═══════════════════════════════════════════════════╗ +║ JBCT COMPLIANCE SCORE: 85/100 ║ +╠═══════════════════════════════════════════════════╣ +║ RETURN TYPES █████████████████░░░ 85% ║ +║ NULL SAFETY ████████████████░░░░ 80% ║ +║ EXCEPTION HYGIENE ██████████████████░░ 90% ║ +║ PATTERN PURITY ███████████████░░░░░ 75% ║ +║ FACTORY METHODS ████████████████████ 100% ║ +║ LAMBDA COMPLIANCE ██████████████████░░ 90% ║ +╚═══════════════════════════════════════════════════╝ +``` + +**JSON**: +```json +{ + "score": 85, + "breakdown": { + "return_types": 85, + "null_safety": 80, + "exception_hygiene": 90, + "pattern_purity": 75, + "factory_methods": 100, + "lambda_compliance": 90 + }, + "filesAnalyzed": 42 +} +``` + +**Badge** (SVG): +- Color: green (≥75), yellow (≥60), orange (≥50), red (<50) +- Format: `JBCT | 85/100` + +### CI Integration + +Use `--baseline` to enforce minimum score in CI: + +```bash +jbct score --baseline 75 src/main/java || exit 1 +``` + +Or with Maven: + +```xml + + org.pragmatica-lite + jbct-maven-plugin + 0.6.0 + + + + score + + + 75 + + + + +``` + ## Key Dependencies -- **pragmatica-lite:core** (0.9.10) - Result, Option, Promise types -- **pragmatica-lite:http-client** (0.9.10) - HTTP operations for upgrade/update -- **pragmatica-lite:toml** (0.9.10) - TOML configuration parsing +- **pragmatica-lite:core** (0.11.2) - Result, Option, Promise types +- **pragmatica-lite:http-client** (0.11.2) - HTTP operations for upgrade/update +- **pragmatica-lite:toml** (0.11.2) - TOML configuration parsing - **java-peglib** - PEG parser generator for CST-based parsing - **picocli** - CLI framework @@ -181,27 +292,37 @@ http.sendString(request) ## Build Commands +**Important**: This project uses its own jbct-maven-plugin (dogfooding), creating a dependency cycle. All reactor builds require `-Djbct.skip=true` to avoid the cycle error. + ```bash # Compile -mvn compile +mvn compile -Djbct.skip=true # Run tests -mvn test +mvn test -Djbct.skip=true # Build distribution (creates tar.gz/zip) -mvn package -DskipTests +mvn package -DskipTests -Djbct.skip=true -# Full verify (includes jbct:check) -mvn verify +# Full verify +mvn verify -Djbct.skip=true -# Format all source files -mvn jbct:format +# Format all source files (run on single module) +mvn jbct:format -pl jbct-core -# Check formatting and lint -mvn jbct:check +# Check formatting and lint (run on single module) +mvn jbct:check -pl jbct-core ``` -**Note**: This project uses its own jbct-maven-plugin (dogfooding). The `jbct:check` goal runs automatically during `mvn verify`. +### Installing to Local Repository + +Due to the dependency cycle, install modules individually: + +```bash +mvn install -DskipTests -pl jbct-core -Djbct.skip=true +mvn install -DskipTests -pl jbct-maven-plugin -Djbct.skip=true +mvn install -DskipTests -pl jbct-cli,slice-processor,slice-processor-tests -Djbct.skip=true +``` ## Distribution diff --git a/docs/slice/reference.md b/docs/slice/reference.md index 5f4e2af..eb3c1db 100644 --- a/docs/slice/reference.md +++ b/docs/slice/reference.md @@ -481,7 +481,7 @@ Generated by `jbct init --slice` with all configuration inlined: 25 - 0.11.1 + 0.11.2 0.8.1 0.6.0 diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java index 05e4f0f..c2932cd 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/GitHubVersionResolver.java @@ -40,7 +40,7 @@ public final class GitHubVersionResolver { private static final Duration API_TIMEOUT = Duration.ofSeconds(10); // Default fallback versions when offline or API fails - private static final String DEFAULT_PRAGMATICA_VERSION = "0.11.1"; + private static final String DEFAULT_PRAGMATICA_VERSION = "0.11.2"; private static final String DEFAULT_AETHER_VERSION = "0.8.1"; private static final String DEFAULT_JBCT_VERSION = "0.6.0"; diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java index 3c43def..0536e66 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java @@ -645,7 +645,7 @@ private String generatePomContent(SliceManifest manifest) { sb.append(" \n"); sb.append(" org.pragmatica-lite\n"); sb.append(" core\n"); - sb.append(" 0.9.10\n"); + sb.append(" 0.11.2\n"); sb.append(" \n"); // And slice-api for runtime sb.append(" \n"); diff --git a/pom.xml b/pom.xml index 29267f8..71e341e 100644 --- a/pom.xml +++ b/pom.xml @@ -53,7 +53,7 @@ UTF-8 - 0.11.1 + 0.11.2 0.8.1 4.7.6 3.9.9 diff --git a/slice-processor/docs/SLICE-FACTORY-GENERATION.md b/slice-processor/docs/SLICE-FACTORY-GENERATION.md index 2f7c478..eec1cb5 100644 --- a/slice-processor/docs/SLICE-FACTORY-GENERATION.md +++ b/slice-processor/docs/SLICE-FACTORY-GENERATION.md @@ -99,9 +99,9 @@ public static Promise create(Aspect aspect, **Rationale**: Aether runtime needs a generic `Slice` interface to invoke methods by name with type tokens. ```java -public static Promise createSlice(Aspect aspect, - SliceInvokerFacade invoker) { - record orderServiceSlice(OrderService delegate) implements Slice { +public static Promise orderServiceSlice(Aspect aspect, + SliceInvokerFacade invoker) { + record orderServiceSlice(OrderService delegate) implements Slice, OrderService { @Override public List> methods() { return List.of( @@ -113,12 +113,19 @@ public static Promise createSlice(Aspect aspect, ) ); } + + @Override + public Promise placeOrder(PlaceOrderRequest request) { + return delegate.placeOrder(request); + } } - return create(aspect, invoker).map(orderServiceSlice::new); + return orderService(aspect, invoker).map(orderServiceSlice::new); } ``` +The slice wrapper record implements both `Slice` (for Aether runtime) and the slice interface (e.g., `OrderService`) to enable type-safe routing integration. + ### D6: Proxy Method Parameter Handling **Decision**: All slice methods require at least one parameter. Multi-parameter methods use synthetic request records (see D8). @@ -238,9 +245,9 @@ public final class OrderProcessorFactory { return Promise.success(aspect.apply(instance)); } - public static Promise createSlice(Aspect aspect, - SliceInvokerFacade invoker) { - record orderProcessorSlice(OrderProcessor delegate) implements Slice { + public static Promise orderProcessorSlice(Aspect aspect, + SliceInvokerFacade invoker) { + record orderProcessorSlice(OrderProcessor delegate) implements Slice, OrderProcessor { @Override public List> methods() { return List.of( @@ -252,9 +259,14 @@ public final class OrderProcessorFactory { ) ); } + + @Override + public Promise processOrder(ProcessOrderRequest request) { + return delegate.processOrder(request); + } } - return create(aspect, invoker) + return orderProcessor(aspect, invoker) .map(orderProcessorSlice::new); } } diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java index 92235fc..6ff7f5b 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java @@ -497,7 +497,7 @@ private void generateCreateSliceMethod(PrintWriter out, SliceModel model) { out.println(" public static Promise " + methodName + "Slice(Aspect<" + sliceName + "> aspect,"); out.println(" SliceInvokerFacade invoker) {"); // Generate local adapter record - out.println(" record " + sliceRecordName + "(" + sliceName + " delegate) implements Slice {"); + out.println(" record " + sliceRecordName + "(" + sliceName + " delegate) implements Slice, " + sliceName + " {"); out.println(" @Override"); out.println(" public List> methods() {"); out.println(" return List.of("); @@ -518,6 +518,14 @@ private void generateCreateSliceMethod(PrintWriter out, SliceModel model) { } out.println(" );"); out.println(" }"); + // Generate delegate methods for the slice interface + for (var method : methods) { + out.println(); + out.println(" @Override"); + out.println(" public " + method.returnType() + " " + method.name() + "(" + method.parameterType() + " " + method.parameterName() + ") {"); + out.println(" return delegate." + method.name() + "(" + method.parameterName() + ");"); + out.println(" }"); + } out.println(" }"); out.println(); out.println(" return " + methodName + "(aspect, invoker)"); diff --git a/slice-processor/src/test/java/org/pragmatica/jbct/slice/SliceProcessorTest.java b/slice-processor/src/test/java/org/pragmatica/jbct/slice/SliceProcessorTest.java index e746fcf..67bb497 100644 --- a/slice-processor/src/test/java/org/pragmatica/jbct/slice/SliceProcessorTest.java +++ b/slice-processor/src/test/java/org/pragmatica/jbct/slice/SliceProcessorTest.java @@ -679,7 +679,7 @@ static UserService userService() { assertThat(factoryContent).contains("delegate::deleteUser"); // Local adapter record - assertThat(factoryContent).contains("record userServiceSlice(UserService delegate) implements Slice"); + assertThat(factoryContent).contains("record userServiceSlice(UserService delegate) implements Slice, UserService"); } @Test From 6421037f535268418be4c183c0d56de2b1cd6e83 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Sat, 31 Jan 2026 15:10:55 +0100 Subject: [PATCH 36/46] ci: fix cyclic dependency by installing modules individually --- .github/workflows/ci.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55342d8..9534bb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,4 +18,12 @@ jobs: distribution: 'temurin' cache: maven - - run: mvn verify -B -Djbct.skip + # Install modules individually to break cyclic dependency + - name: Install jbct-core + run: mvn install -B -DskipTests -pl jbct-core -Djbct.skip=true + + - name: Install jbct-maven-plugin + run: mvn install -B -DskipTests -pl jbct-maven-plugin -Djbct.skip=true + + - name: Build and test all modules + run: mvn verify -B -pl jbct-core,jbct-cli,slice-processor,slice-processor-tests -Djbct.skip=true From 9f82c421502aad2e79df10f6ebf9cb856a549e3e Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Sat, 31 Jan 2026 15:12:01 +0100 Subject: [PATCH 37/46] ci: install parent POM to fix dependency resolution --- .github/workflows/ci.yml | 3 +++ .github/workflows/release.yml | 12 +++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9534bb6..5aee222 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,9 @@ jobs: cache: maven # Install modules individually to break cyclic dependency + - name: Install parent POM + run: mvn install -B -N -Djbct.skip=true + - name: Install jbct-core run: mvn install -B -DskipTests -pl jbct-core -Djbct.skip=true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 99599ec..9970120 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,8 +19,18 @@ jobs: distribution: 'temurin' cache: maven + # Install modules individually to break cyclic dependency + - name: Install parent POM + run: mvn install -B -N -Djbct.skip=true + + - name: Install jbct-core + run: mvn install -B -DskipTests -pl jbct-core -Djbct.skip=true + + - name: Install jbct-maven-plugin + run: mvn install -B -DskipTests -pl jbct-maven-plugin -Djbct.skip=true + - name: Build - run: mvn package -DskipTests -Djbct.skip -B + run: mvn package -B -DskipTests -pl jbct-cli -Djbct.skip=true - name: Create or update release env: From 5d81abfe7e50a858373c8f543ebdac9064671569 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Sat, 31 Jan 2026 15:14:18 +0100 Subject: [PATCH 38/46] ci: exclude slice-processor (depends on unpublished Aether libs) --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5aee222..fedba3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,5 +28,6 @@ jobs: - name: Install jbct-maven-plugin run: mvn install -B -DskipTests -pl jbct-maven-plugin -Djbct.skip=true - - name: Build and test all modules - run: mvn verify -B -pl jbct-core,jbct-cli,slice-processor,slice-processor-tests -Djbct.skip=true + # Note: slice-processor modules excluded - they depend on unpublished Aether libraries + - name: Build and test core modules + run: mvn verify -B -pl jbct-core,jbct-cli -Djbct.skip=true From 8b8edffadee662b9053ef765e8e8f2367882f6cd Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Sat, 31 Jan 2026 21:51:28 +0100 Subject: [PATCH 39/46] fix: address all PR review comments - RouteSourceGenerator: use Elements#getTypeElement instead of Class.forName - FactoryClassGenerator: use lowercaseFirst() for factory names, fix infra chain threading - CollectSliceDepsMojo: tighten base.artifact validation, document in Javadoc, scan Maven resources - GenerateBlueprintMojo: fix UNRESOLVED dependency edge resolution - SliceConfig: fix or(() -> 3) to or(3) - SliceProjectInitializer: rewrite test template without .unwrap() - Docs: fix CHANGELOG headings, add JBCT 0.6.0+ to quickstart, add text lang to code block --- CHANGELOG.md | 36 ++++++++---------- CLAUDE.md | 16 ++++---- docs/slice/quickstart.md | 2 +- .../jbct/init/SliceProjectInitializer.java | 14 +++---- .../pragmatica/jbct/slice/SliceConfig.java | 2 +- .../jbct/maven/CollectSliceDepsMojo.java | 38 ++++++++++++++----- .../jbct/maven/GenerateBlueprintMojo.java | 12 +++--- slice-processor/docs/HTTP-ROUTE-GENERATION.md | 2 +- .../pragmatica/jbct/slice/SliceProcessor.java | 18 ++++----- .../generator/FactoryClassGenerator.java | 37 ++++++++++-------- .../slice/routing/RouteSourceGenerator.java | 5 ++- 11 files changed, 104 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e70e643..e5b8a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ ### Changed - Slice init: added tinylog dependencies (2.7.0) in test scope - Slice init: added tinylog.properties configuration file in test resources +- Build: Bump Aether to 0.8.1 +- CI: Re-enabled slice-processor-tests module +- Slice init: Java version 21 to 25 +- Slice init: updated default versions (Pragmatica Lite 0.11.1, Aether 0.8.1, JBCT 0.6.0) +- Slice init: implementation pattern changed to record-based (nested record in interface) +- Slice init: removed Config dependency from template (factory now parameterless) +- Slice init: added annotation processor configuration to maven-compiler-plugin +- Slice init: added compilerArgs with `-Aslice.groupId` and `-Aslice.artifactId` +- Slice init: removed separate *Impl.java, SampleRequest.java, SampleResponse.java files +- Slice init: Request/Response/Error records now nested in @Slice interface +- Slice init: removed "Sample" prefix from Request/Response records +- Slice init: inner implementation record uses lowercased slice name, not "Impl" +- Init: version resolution uses running binary version as minimum (overrides GitHub API if newer) +- Init: version comparison uses Result-based `Number.parseInt()` from pragmatica-lite ### Fixed - RFC-0004 compliance: removed non-standard slice-api.properties generation @@ -23,31 +37,13 @@ - PackageSlicesMojo: fixed JAR overwriting bug (multiple slices now create separate JARs) - FactoryClassGenerator: infrastructure deps (CacheService, etc.) now use InfraStore.instance().get() - FactoryClassGenerator: only slice dependencies are proxied via SliceInvokerFacade -- FactoryClassGenerator: reduced flatMap chain depth (e.g., 13→3 for UrlShortener with mixed deps) +- FactoryClassGenerator: reduced flatMap chain depth (e.g., 13 to 3 for UrlShortener with mixed deps) - PackageSlicesMojo: bytecode transformation replaces UNRESOLVED versions with actual versions (strips semver prefix ^/~) - -### Changed -- Build: bump Aether to 0.8.1 -- CI: re-enabled slice-processor-tests module -- Slice init: Java version 21 → 25 -- Slice init: updated default versions (Pragmatica Lite 0.11.1, Aether 0.8.1, JBCT 0.6.0) -- Slice init: implementation pattern changed to record-based (nested record in interface) -- Slice init: removed Config dependency from template (factory now parameterless) -- Slice init: added annotation processor configuration to maven-compiler-plugin -- Slice init: added compilerArgs with `-Aslice.groupId` and `-Aslice.artifactId` -- Slice init: removed separate *Impl.java, SampleRequest.java, SampleResponse.java files -- Slice init: Request/Response/Error records now nested in @Slice interface -- Slice init: removed "Sample" prefix from Request/Response records -- Slice init: inner implementation record uses lowercased slice name, not "Impl" -- Init: version resolution uses running binary version as minimum (overrides GitHub API if newer) -- Init: version comparison uses Result-based `Number.parseInt()` from pragmatica-lite - -### Fixed - Slice init: `ValidationError` now extends `Cause` (required for `Result.failure`) - Slice init: added missing `Cause` import to template - Slice init: `Promise.success()` instead of `Promise.successful()` - Slice init: implemented `message()` method in `ValidationError.EmptyValue` -- Slice init: test template now uses `.unwrap()` instead of `.getOrThrow()` +- Slice init: test template now uses monadic composition instead of `.unwrap()` ## [0.5.0] - 2026-01-20 diff --git a/CLAUDE.md b/CLAUDE.md index 196a27f..6ea3eff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,14 +106,14 @@ overall_score = Σ(category_score[i] × weight[i]) ### Scoring Categories -| Category | Weight | Rules Included | -|----------|--------|----------------| -| Return Types | 25% | JBCT-RET-01, JBCT-RET-02, JBCT-RET-03, JBCT-NEST-02, JBCT-RES-01 | -| Null Safety | 20% | JBCT-NULL-01, JBCT-NULL-02 | -| Exception Hygiene | 20% | JBCT-EXC-01, JBCT-EXC-02, JBCT-NAME-02 | -| Pattern Purity | 15% | JBCT-PAT-01, JBCT-NEST-01, JBCT-CHAIN-01, JBCT-IMPORT-01, JBCT-FQN-01, JBCT-DOMAIN-01, JBCT-LOOP-01, JBCT-LOG-01, JBCT-LOG-02 | -| Factory Methods | 10% | JBCT-VO-01, JBCT-VO-02, JBCT-VO-03, JBCT-VO-04, JBCT-ERR-01, JBCT-NAME-01, JBCT-PARSE-01 | -| Lambda Compliance | 10% | JBCT-LAM-01, JBCT-LAM-02, JBCT-LAM-03, JBCT-LAM-04, JBCT-STATIC-01 | +| Category | Weight | Rules Included | +|--------------------|--------|----------------| +| Return Types | 25% | JBCT-RET-01, JBCT-RET-02, JBCT-RET-03, JBCT-NEST-02, JBCT-RES-01 | +| Null Safety | 20% | JBCT-NULL-01, JBCT-NULL-02 | +| Exception Hygiene | 20% | JBCT-EXC-01, JBCT-EXC-02, JBCT-NAME-02 | +| Pattern Purity | 15% | JBCT-PAT-01, JBCT-NEST-01, JBCT-CHAIN-01, JBCT-IMPORT-01, JBCT-FQN-01, JBCT-DOMAIN-01, JBCT-LOOP-01, JBCT-LOG-01, JBCT-LOG-02 | +| Factory Methods | 10% | JBCT-VO-01, JBCT-VO-02, JBCT-VO-03, JBCT-VO-04, JBCT-ERR-01, JBCT-NAME-01, JBCT-PARSE-01 | +| Lambda Compliance | 10% | JBCT-LAM-01, JBCT-LAM-02, JBCT-LAM-03, JBCT-LAM-04, JBCT-STATIC-01 | ### Output Formats diff --git a/docs/slice/quickstart.md b/docs/slice/quickstart.md index e0614f8..45d9ddf 100644 --- a/docs/slice/quickstart.md +++ b/docs/slice/quickstart.md @@ -6,7 +6,7 @@ Create and deploy a slice in 5 minutes. - Java 25+ - Maven 3.8+ -- JBCT CLI installed (`jbct --version` should work) +- JBCT CLI 0.6.0+ installed (`jbct --version` should work) ## Step 1: Create Project diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java index 06d3950..96fa155 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java @@ -614,6 +614,7 @@ public Promise process(Request request) { private static final String SLICE_TEST_TEMPLATE = """ package {{basePackage}}; + import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @@ -624,13 +625,12 @@ class {{sliceName}}Test { @Test void should_process_request() { - var request = {{sliceName}}.Request.request("test") - .unwrap(); - - var response = slice.process(request).await(); - - assertThat(response.isSuccess()).isTrue(); - response.onSuccess(r -> assertThat(r.result()).isEqualTo("Processed: test")); + {{sliceName}}.Request.request("test") + .onFailure(Assertions::fail) + .onSuccess(request -> slice.process(request) + .await() + .onFailure(Assertions::fail) + .onSuccess(r -> assertThat(r.result()).isEqualTo("Processed: test"))); } } """; diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceConfig.java b/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceConfig.java index 243730c..5ff7483 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceConfig.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceConfig.java @@ -41,7 +41,7 @@ public static Result load(Path configPath) { private static SliceConfig fromTomlDocument(org.pragmatica.config.toml.TomlDocument toml) { var instances = toml.getInt("blueprint", "instances") - .or(() -> 3); + .or(3); var blueprint = BlueprintConfig.blueprintConfig(instances); return new SliceConfig(blueprint); } diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java index 1380cb2..6a6670e 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java @@ -27,6 +27,7 @@ *
    *
  • slice.interface - the slice interface fully qualified name
  • *
  • slice.artifactId - the slice artifact ID
  • + *
  • base.artifact - the groupId:baseArtifactId for dependency resolution
  • *
* *

Writes mappings to slice-deps.properties in format: @@ -99,7 +100,13 @@ private void extractSliceManifest(File jarFile, String version, Properties mappi var baseArtifact = props.getProperty("base.artifact"); String groupId; if (baseArtifact != null && baseArtifact.contains(":")) { - groupId = baseArtifact.split(":") [0]; + var parts = baseArtifact.split(":"); + if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) { + getLog().warn("Invalid base.artifact format in " + jarFile.getName() + " (" + entryName + + "): expected 'groupId:artifactId', got '" + baseArtifact + "'"); + continue; + } + groupId = parts[0]; } else { getLog().warn("Missing or invalid base.artifact in " + jarFile.getName() + " (" + entryName + ")"); continue; @@ -127,12 +134,18 @@ private void writeOutput(Properties mappings) throws MojoExecutionException { } private void validateHttpRoutingDependency() throws MojoExecutionException { - // Scan for routes.toml files in src/main/resources - var resourcesDir = new File(project.getBasedir(), "src/main/resources"); - if (!resourcesDir.exists()) { + // Scan for routes.toml files in configured resource directories + var resourceDirs = project.getResources() + .stream() + .map(resource -> new File(resource.getDirectory())) + .filter(File::exists) + .toList(); + if (resourceDirs.isEmpty()) { return; } - var routesTomlFiles = findRoutesTomlFiles(resourcesDir.toPath()); + var routesTomlFiles = resourceDirs.stream() + .flatMap(dir -> findRoutesTomlFiles(dir.toPath()).stream()) + .toList(); if (routesTomlFiles.isEmpty()) { return; } @@ -143,9 +156,16 @@ private void validateHttpRoutingDependency() throws MojoExecutionException { "http-routing-adapter".equals(artifact.getArtifactId())); if (!hasRoutingAdapter) { var sliceNames = routesTomlFiles.stream() - .map(path -> resourcesDir.toPath() - .relativize(path) - .toString()) + .map(path -> { + for (var dir : resourceDirs) { + if (path.startsWith(dir.toPath())) { + return dir.toPath() + .relativize(path) + .toString(); + } + } + return path.toString(); + }) .toList(); var message = String.format(""" HTTP routing configured but dependency missing. @@ -173,7 +193,7 @@ private List findRoutesTomlFiles(Path resourcesDir) { .equals("routes.toml")) .forEach(routesTomlFiles::add); } catch (IOException e) { - getLog().debug("Error scanning resources directory: " + e.getMessage()); + getLog().warn("Error scanning resources directory: " + e.getMessage()); } return routesTomlFiles; } diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/GenerateBlueprintMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/GenerateBlueprintMojo.java index 40e3f09..2db6f59 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/GenerateBlueprintMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/GenerateBlueprintMojo.java @@ -131,13 +131,15 @@ private void resolveExternalDependencies(SliceManifest manifest, if (graph.containsKey(depArtifact)) { continue; } - // Skip UNRESOLVED local dependencies - they're already in graph with correct version + // Handle UNRESOLVED local dependencies - find matching resolved key in graph if ("UNRESOLVED".equals(dep.version())) { var artifactWithoutVersion = dep.artifact(); - var isLocal = graph.keySet() - .stream() - .anyMatch(key -> key.startsWith(artifactWithoutVersion + ":")); - if (isLocal) { + var resolvedKey = graph.keySet() + .stream() + .filter(key -> key.startsWith(artifactWithoutVersion + ":")) + .findFirst(); + if (resolvedKey.isPresent()) { + // Dependency already in graph with resolved version, skip adding duplicate continue; } } diff --git a/slice-processor/docs/HTTP-ROUTE-GENERATION.md b/slice-processor/docs/HTTP-ROUTE-GENERATION.md index 43f238a..d455c7f 100644 --- a/slice-processor/docs/HTTP-ROUTE-GENERATION.md +++ b/slice-processor/docs/HTTP-ROUTE-GENERATION.md @@ -23,7 +23,7 @@ This document describes the automatic generation of HTTP route handling code fro **Existing projects**: If adding routing to an existing slice project, add the dependency above. **Compile-time validation**: If `routes.toml` exists but the dependency is missing, compilation fails with: -``` +```text ERROR: HTTP routing configured but dependency missing. Add to pom.xml: diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java index 1bf5c58..bf5765b 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java @@ -58,7 +58,7 @@ public synchronized void init(javax.annotation.processing.ProcessingEnvironment this.factoryGenerator = new FactoryClassGenerator(filer, elements, types, versionResolver); this.manifestGenerator = new ManifestGenerator(filer, versionResolver, options); this.errorDiscovery = new ErrorTypeDiscovery(processingEnv); - this.routeGenerator = new RouteSourceGenerator(filer, processingEnv.getMessager()); + this.routeGenerator = new RouteSourceGenerator(filer, processingEnv.getMessager(), elements); } @Override @@ -128,10 +128,10 @@ private void processSliceInterface(TypeElement interfaceElement) { } private void generateArtifacts(TypeElement interfaceElement, SliceModel sliceModel) { - generateFactory(interfaceElement, sliceModel) - .flatMap(_ -> generateRoutes(interfaceElement, sliceModel)) - .flatMap(routesClassOpt -> generateSliceManifest(interfaceElement, sliceModel, routesClassOpt)) - .onFailure(cause -> error(interfaceElement, cause.message())); + generateFactory(interfaceElement, sliceModel).flatMap(_ -> generateRoutes(interfaceElement, sliceModel)) + .flatMap(routesClassOpt -> generateSliceManifest(interfaceElement, sliceModel, routesClassOpt)) + .onFailure(cause -> error(interfaceElement, + cause.message())); } private Result generateFactory(TypeElement interfaceElement, SliceModel sliceModel) { @@ -152,9 +152,8 @@ private Result generateSliceManifest(TypeElement interfaceElement, private Result> generateRoutes(TypeElement interfaceElement, SliceModel sliceModel) { var packageName = sliceModel.packageName(); return loadRouteConfig(packageName) - .flatMap(configOpt -> configOpt.fold( - () -> Result.success(Option.none()), - config -> generateRoutesFromConfig(interfaceElement, sliceModel, config))); + .flatMap(configOpt -> configOpt.fold(() -> Result.success(Option.none()), + config -> generateRoutesFromConfig(interfaceElement, sliceModel, config))); } private Result> loadRouteConfig(String packageName) { @@ -181,7 +180,8 @@ private Result> generateRoutesFromConfig(TypeElement interfaceEle SliceModel sliceModel, RouteConfig config) { var packageName = sliceModel.packageName(); - return errorDiscovery.discover(packageName, config.errors()) + return errorDiscovery.discover(packageName, + config.errors()) .flatMap(errorMappings -> routeGenerator.generate(sliceModel, config, errorMappings)) .onSuccess(qualifiedNameOpt -> { qualifiedNameOpt.onPresent(routeServiceEntries::add); diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java index 6ff7f5b..cc5490c 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java @@ -260,11 +260,15 @@ private void generateAspectCreateChain(PrintWriter out, } // Handle infra dependencies first if (!infraDeps.isEmpty()) { - for (var infra : infraDeps) { - out.println(" .flatMap(" + (cacheVarNames.isEmpty() - ? "factory" - : cacheVarNames.getLast()) + " -> " + generateInfraStoreCall(infra) + var infraPrevVar = cacheVarNames.isEmpty() + ? "factory" + : cacheVarNames.getLast(); + for (int i = 0; i < infraDeps.size(); i++) { + var infra = infraDeps.get(i); + var infraVarName = infra.parameterName(); + out.println(" .flatMap(" + infraPrevVar + " -> " + generateInfraStoreCall(infra) + ")"); + infraPrevVar = infraVarName; } } // Handle slice dependencies @@ -276,16 +280,20 @@ private void generateAspectCreateChain(PrintWriter out, allHandles.add(new HandleInfo(dep, proxyMethod)); } } - var prevVar = infraDeps.isEmpty() - ? (cacheVarNames.isEmpty() - ? "factory" - : cacheVarNames.getLast()) - : infraDeps.getLast() - .parameterName(); + // Determine previous variable: last infra dep, or last cache var, or factory + String slicePrevVar; + if (!infraDeps.isEmpty()) { + slicePrevVar = infraDeps.getLast() + .parameterName(); + } else if (!cacheVarNames.isEmpty()) { + slicePrevVar = cacheVarNames.getLast(); + } else { + slicePrevVar = "factory"; + } for (var handle : allHandles) { - out.println(" .flatMap(" + prevVar + " -> " + generateMethodHandleCall(handle) + out.println(" .flatMap(" + slicePrevVar + " -> " + generateMethodHandleCall(handle) + ")"); - prevVar = handle.varName(); + slicePrevVar = handle.varName(); } } // Final map to create wrapper @@ -706,10 +714,7 @@ private String generateInfraStoreCall(DependencyModel infra) { } private String toFactoryMethodName(String className) { - if (className == null || className.isEmpty()) { - return className; - } - return Character.toLowerCase(className.charAt(0)) + className.substring(1); + return lowercaseFirst(className); } private void generateDependencyInstantiation(PrintWriter out, diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java index 64be57b..9e870cb 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java @@ -9,6 +9,7 @@ import javax.annotation.processing.Filer; import javax.annotation.processing.Messager; +import javax.lang.model.util.Elements; import javax.tools.Diagnostic; import javax.tools.JavaFileObject; import java.io.IOException; @@ -134,10 +135,12 @@ public class RouteSourceGenerator { private final Filer filer; private final Messager messager; + private final Elements elements; - public RouteSourceGenerator(Filer filer, Messager messager) { + public RouteSourceGenerator(Filer filer, Messager messager, Elements elements) { this.filer = filer; this.messager = messager; + this.elements = elements; } /** From 6ba7e7bd9c52c7eeb3c9fd003aa76357b2d23eb0 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Sat, 31 Jan 2026 22:28:37 +0100 Subject: [PATCH 40/46] refactor: fix JBCT compliance issues across codebase Co-Authored-By: Claude Opus 4.5 --- README.md | 41 +++++++++++++++ .../org/pragmatica/jbct/cli/InitCommand.java | 52 +++++++++++-------- .../org/pragmatica/jbct/cli/JbctCommand.java | 1 + .../pragmatica/jbct/cli/UpgradeCommand.java | 25 ++++----- .../pragmatica/jbct/config/ConfigLoader.java | 3 +- .../jbct/format/cst/CstFormatter.java | 40 ++++++-------- .../pragmatica/jbct/lint/cst/CstLinter.java | 7 ++- .../jbct/lint/cst/SuppressionExtractor.java | 14 +++-- .../pragmatica/jbct/slice/SliceProcessor.java | 10 ++-- .../slice/routing/RouteSourceGenerator.java | 15 ++++-- 10 files changed, 127 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index c3ef57d..1d5de07 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,39 @@ Exit codes: - `1` - Format or lint issues found - `2` - Internal error (parse failure, etc.) +### Score + +Calculate JBCT compliance score (0-100): + +```bash +jbct score src/main/java +``` + +Options: +- `--format` / `-f` `` - Output format (default: terminal) +- `--baseline` / `-b` `` - Fail if score below threshold +- `--verbose` / `-v` - Show detailed output +- `--config ` - Path to configuration file + +Output formats: +- `terminal` - Progress bars with category breakdown +- `json` - Machine-readable JSON with full breakdown +- `badge` - SVG badge for README + +Example with baseline: + +```bash +jbct score --baseline 75 src/main/java # Fail if score < 75 +``` + +The score is calculated using density + severity weighting across 6 categories: +- **Return Types** (25%) - Four return kinds compliance +- **Null Safety** (20%) - No null returns or parameters +- **Exception Hygiene** (20%) - No business exceptions +- **Pattern Purity** (15%) - Clean functional composition +- **Factory Methods** (10%) - Parse-don't-validate pattern +- **Lambda Compliance** (10%) - Minimal lambda complexity + ### Upgrade Self-update to the latest version: @@ -206,6 +239,7 @@ Priority chain: | `jbct:format-check` | Check formatting (fail if issues) | verify | | `jbct:lint` | Run lint rules | verify | | `jbct:check` | Combined format-check + lint | verify | +| `jbct:score` | Calculate JBCT compliance score | verify | | `jbct:collect-slice-deps` | Collect slice API dependencies | generate-sources | | `jbct:verify-slice` | Validate slice configuration | verify | @@ -235,6 +269,13 @@ Full check (format + lint): mvn jbct:check ``` +Calculate compliance score: + +```bash +mvn jbct:score +mvn jbct:score -Djbct.score.baseline=75 +``` + ### Binding to Build Lifecycle Add executions to run automatically: diff --git a/jbct-cli/src/main/java/org/pragmatica/jbct/cli/InitCommand.java b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/InitCommand.java index 05bf49e..7b9313d 100644 --- a/jbct-cli/src/main/java/org/pragmatica/jbct/cli/InitCommand.java +++ b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/InitCommand.java @@ -129,8 +129,8 @@ public Integer call() { aiToolsInstalled = installer.install() .onFailure(cause -> System.err.println("Warning: Failed to install AI tools: " + cause.message())) .onSuccess(this::printAiToolsResult) - .fold(_ -> false, - files -> !files.isEmpty()); + .map(files -> !files.isEmpty()) + .or(false); } } // Print summary @@ -164,7 +164,8 @@ private boolean initRegularProject() { return initializer.initialize() .onFailure(cause -> System.err.println("Error: " + cause.message())) .onSuccess(this::printCreatedFiles) - .fold(_ -> false, _ -> true); + .map(_ -> true) + .or(false); } private void printCreatedFiles(java.util.List createdFiles) { @@ -177,7 +178,9 @@ private void printCreatedFiles(java.util.List createdFiles) { } private boolean hasVersionOverrides() { - return pragmaticaVersion != null || aetherVersion != null || jbctVersion != null; + return org.pragmatica.lang.Option.option(pragmaticaVersion).isPresent() + || org.pragmatica.lang.Option.option(aetherVersion).isPresent() + || org.pragmatica.lang.Option.option(jbctVersion).isPresent(); } private String effectivePragmaticaVersion() { @@ -204,7 +207,8 @@ private boolean initSliceProject() { .onSuccess(createdFiles -> printSliceCreatedFiles(createdFiles, initializer.sliceName()))) .onFailure(cause -> System.err.println("Error: " + cause.message())) - .fold(_ -> false, _ -> true); + .map(_ -> true) + .or(false); } private void printSliceCreatedFiles(java.util.List createdFiles, String sliceName) { @@ -227,16 +231,16 @@ private void printAiToolsResult(java.util.List installedFiles) { } private static boolean isValidPackageName(String packageName) { - if (packageName == null || packageName.isBlank()) { - return false; - } - if (packageName.startsWith(".") || packageName.endsWith(".")) { - return false; - } - if (packageName.contains("..")) { - return false; - } - var segments = packageName.split("\\."); + return org.pragmatica.lang.Option.option(packageName) + .filter(s -> !s.isBlank()) + .filter(s -> !s.startsWith(".") && !s.endsWith(".")) + .filter(s -> !s.contains("..")) + .map(s -> s.split("\\.")) + .filter(InitCommand::allValidIdentifiers) + .isPresent(); + } + + private static boolean allValidIdentifiers(String[] segments) { for (var segment : segments) { if (!isValidJavaIdentifier(segment)) { return false; @@ -246,19 +250,21 @@ private static boolean isValidPackageName(String packageName) { } private static boolean isValidJavaIdentifier(String identifier) { - if (identifier == null || identifier.isEmpty()) { - return false; - } - if (!Character.isJavaIdentifierStart(identifier.charAt(0))) { - return false; - } + return org.pragmatica.lang.Option.option(identifier) + .filter(s -> !s.isEmpty()) + .filter(s -> Character.isJavaIdentifierStart(s.charAt(0))) + .filter(InitCommand::allCharsValidIdentifierParts) + .filter(s -> !isJavaKeyword(s)) + .isPresent(); + } + + private static boolean allCharsValidIdentifierParts(String identifier) { for (int i = 1; i < identifier.length(); i++) { if (!Character.isJavaIdentifierPart(identifier.charAt(i))) { return false; } } - // Reject Java keywords - return ! isJavaKeyword(identifier); + return true; } private static boolean isJavaKeyword(String word) { diff --git a/jbct-cli/src/main/java/org/pragmatica/jbct/cli/JbctCommand.java b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/JbctCommand.java index 4be4dc7..5c37df5 100644 --- a/jbct-cli/src/main/java/org/pragmatica/jbct/cli/JbctCommand.java +++ b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/JbctCommand.java @@ -15,6 +15,7 @@ subcommands = {FormatCommand.class, LintCommand.class, CheckCommand.class, + ScoreCommand.class, UpgradeCommand.class, InitCommand.class, UpdateCommand.class, diff --git a/jbct-cli/src/main/java/org/pragmatica/jbct/cli/UpgradeCommand.java b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/UpgradeCommand.java index a1e9003..bb0cc2e 100644 --- a/jbct-cli/src/main/java/org/pragmatica/jbct/cli/UpgradeCommand.java +++ b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/UpgradeCommand.java @@ -41,8 +41,8 @@ public Integer call() { System.out.println("Checking for updates..."); return checker.checkLatestRelease() .onFailure(cause -> System.err.println("Error: " + cause.message())) - .fold(_ -> 1, - release -> handleRelease(release, currentVersion)); + .map(release -> handleRelease(release, currentVersion)) + .or(1); } private int handleRelease(GitHubReleaseChecker.ReleaseInfo release, String currentVersion) { @@ -67,9 +67,8 @@ private int handleRelease(GitHubReleaseChecker.ReleaseInfo release, String curre System.err.println("Error: " + cause.message()); System.err.println("Please download manually from GitHub."); }) - .fold(_ -> 1, - url -> performUpgrade(url, - release.version())); + .map(url -> performUpgrade(url, release.version())) + .or(1); } private int performUpgrade(String downloadUrl, String version) { @@ -108,13 +107,15 @@ private Result downloadLatestRelease() { private Result downloadRelease(GitHubReleaseChecker.ReleaseInfo release) { return release.downloadUrl() .toResult(Causes.cause("No downloadable JAR found in release")) - .flatMap(url -> { - System.out.println("Downloading version " + release.version() + "..."); - var installer = JarInstaller.jarInstaller(); - var targetPath = JarInstaller.defaultInstallPath(); - return installer.install(url, targetPath) - .map(_ -> release.version()); - }); + .flatMap(url -> downloadAndInstall(url, release.version())); + } + + private Result downloadAndInstall(String url, String version) { + System.out.println("Downloading version " + version + "..."); + var installer = JarInstaller.jarInstaller(); + var targetPath = JarInstaller.defaultInstallPath(); + return installer.install(url, targetPath) + .map(_ -> version); } private void printInstallSuccess(String version) { diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/config/ConfigLoader.java b/jbct-core/src/main/java/org/pragmatica/jbct/config/ConfigLoader.java index 924f904..0a6ef09 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/config/ConfigLoader.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/config/ConfigLoader.java @@ -64,8 +64,7 @@ static Option loadFromFile(Path path) { } return TomlParser.parseFile(path) .map(JbctConfig::fromToml) - .fold(_ -> Option.none(), - Option::option); + .option(); } /** diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/format/cst/CstFormatter.java b/jbct-core/src/main/java/org/pragmatica/jbct/format/cst/CstFormatter.java index a2ace4c..aff6363 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/format/cst/CstFormatter.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/format/cst/CstFormatter.java @@ -49,31 +49,23 @@ public Result isFormatted(SourceFile source) { private Result parse(SourceFile source) { var result = parser.parseWithDiagnostics(source.content()); - if (result.isSuccess() && result.node() - .isPresent()) { - return Result.success(result.node() - .unwrap()); + if (result.isSuccess()) { + return result.node() + .toResult(FormattingError.parseError(source.fileName(), 1, 1, "Parse error")); } - var diag = result.diagnostics() - .stream() - .findFirst(); - if (diag.isPresent()) { - var span = diag.get() - .span(); - return FormattingError.parseError(source.fileName(), - span.start() - .line(), - span.start() - .column(), - diag.get() - .message()) - .result(); - } - return FormattingError.parseError(source.fileName(), - 1, - 1, - "Parse error") - .result(); + return result.diagnostics() + .stream() + .findFirst() + .map(d -> FormattingError.parseError(source.fileName(), + d.span() + .start() + .line(), + d.span() + .start() + .column(), + d.message())) + .orElse(FormattingError.parseError(source.fileName(), 1, 1, "Parse error")) + .result(); } private String formatCst(CstNode root, String source) { diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/lint/cst/CstLinter.java b/jbct-core/src/main/java/org/pragmatica/jbct/lint/cst/CstLinter.java index 07fa985..db9f9c1 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/lint/cst/CstLinter.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/lint/cst/CstLinter.java @@ -75,10 +75,9 @@ private boolean passesLintRules(List diagnostics) { private Result parse(SourceFile source) { var result = parser.parseWithDiagnostics(source.content()); - if (result.isSuccess() && result.node() - .isPresent()) { - return Result.success(result.node() - .unwrap()); + if (result.isSuccess()) { + return result.node() + .toResult(Causes.cause("Parse error in " + source.fileName())); } var errorMsg = result.diagnostics() .stream() diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/lint/cst/SuppressionExtractor.java b/jbct-core/src/main/java/org/pragmatica/jbct/lint/cst/SuppressionExtractor.java index 1434646..8d756fc 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/lint/cst/SuppressionExtractor.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/lint/cst/SuppressionExtractor.java @@ -67,14 +67,12 @@ public static List extractSuppressions(CstNode root, String source) continue; } // Find the scope (declaration that this annotation applies to) - var scopeOpt = findAnnotatedDeclaration(root, annotation); - if (scopeOpt.isEmpty()) { - continue; - } - var scopeNode = scopeOpt.unwrap(); - var startLine = startLine(scopeNode); - var endLine = endLine(scopeNode); - suppressions.add(Suppression.suppression(ruleIds, startLine, endLine)); + findAnnotatedDeclaration(root, annotation) + .onPresent(scopeNode -> { + var startLine = startLine(scopeNode); + var endLine = endLine(scopeNode); + suppressions.add(Suppression.suppression(ruleIds, startLine, endLine)); + }); } return suppressions; } diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java index bf5765b..d4b1cbc 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java @@ -219,9 +219,13 @@ private void writeRouteServiceFile() { } private static String toKebabCase(String camelCase) { - if (camelCase == null || camelCase.isEmpty()) { - return camelCase; - } + return Option.option(camelCase) + .filter(s -> !s.isEmpty()) + .map(SliceProcessor::doToKebabCase) + .or(""); + } + + private static String doToKebabCase(String camelCase) { var result = new StringBuilder(); result.append(Character.toLowerCase(camelCase.charAt(0))); for (int i = 1; i < camelCase.length(); i++) { diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java index 9e870cb..7d9c2ae 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java @@ -290,8 +290,10 @@ private void generateRoutesMethod(PrintWriter out, SliceModel model, RouteConfig var entry = validRoutes.get(i); var handlerName = entry.getKey(); var routeDsl = entry.getValue(); - var method = methodMap.get(handlerName); - generateRoute(out, routeConfig.prefix(), routeDsl, method, i < validRoutes.size() - 1); + var hasMore = i < validRoutes.size() - 1; + // methodOpt guaranteed present - we validated above + Option.option(methodMap.get(handlerName)) + .onPresent(method -> generateRoute(out, routeConfig.prefix(), routeDsl, method, hasMore)); } out.println(" );"); out.println(" }"); @@ -582,9 +584,12 @@ private String typeToQueryParameter(String type) { * Handles quotes, backslashes, and common control characters. */ private String escapeJavaString(String input) { - if (input == null) { - return ""; - } + return Option.option(input) + .map(this::doEscapeJavaString) + .or(""); + } + + private String doEscapeJavaString(String input) { var sb = new StringBuilder(input.length()); for (int i = 0; i < input.length(); i++) { char c = input.charAt(i); From 0396e123e55040172cd8ab2b77723525bcfe2c89 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Sat, 31 Jan 2026 22:49:42 +0100 Subject: [PATCH 41/46] fix(slice): ensure deterministic route generation order Co-Authored-By: Claude Opus 4.5 --- .../org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java | 1 + 1 file changed, 1 insertion(+) diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java index 7d9c2ae..eb10420 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteSourceGenerator.java @@ -260,6 +260,7 @@ private void generateRoutesMethod(PrintWriter out, SliceModel model, RouteConfig var routeEntries = routeConfig.routes() .entrySet() .stream() + .sorted(Map.Entry.comparingByKey()) .toList(); // Filter valid routes and report errors for invalid ones var validRoutes = new ArrayList>(); From b6e632039907ee58a60e2848c956409b2fa7e6bc Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Sat, 31 Jan 2026 23:06:35 +0100 Subject: [PATCH 42/46] fix: address all JBCT compliance issues from parallel review - SliceProcessor: use ConcurrentHashMap and synchronizedSet for thread safety - PackageSlicesMojo: return Optional instead of null - JbctConfig/RouteConfig/ErrorPatternConfig: extract merge lambdas to methods - Version: log IOException at DEBUG level instead of swallowing - KeyExtractorInfo: remove redundant compact constructor validation - JarInstaller: wrap System.getProperty in Option with fallback - ConfigLoader: use recursive Option pattern instead of null loop - SliceManifest: use Option.option() for property access - SliceProjectValidator: use onSuccess/onFailure instead of fold - InitCommand: catch IOException specifically instead of Exception - Tests: replace .unwrap() with monadic assertions in 3 test files Co-Authored-By: Claude Opus 4.5 --- .../org/pragmatica/jbct/cli/InitCommand.java | 2 +- .../org/pragmatica/jbct/cli/ScoreCommand.java | 195 ++++++++++++++++++ .../java/org/pragmatica/jbct/cli/Version.java | 8 +- .../pragmatica/jbct/config/ConfigLoader.java | 21 +- .../pragmatica/jbct/config/JbctConfig.java | 57 +++-- .../jbct/init/SliceProjectValidator.java | 14 +- .../jbct/score/RuleCategoryMapping.java | 87 ++++++++ .../jbct/score/ScoreCalculator.java | 95 +++++++++ .../pragmatica/jbct/score/ScoreCategory.java | 48 +++++ .../pragmatica/jbct/score/ScoreResult.java | 18 ++ .../pragmatica/jbct/slice/SliceManifest.java | 67 +++--- .../pragmatica/jbct/upgrade/JarInstaller.java | 5 +- .../jbct/format/cst/CstFormatterTest.java | 18 +- .../jbct/lint/cst/CstLinterTest.java | 3 +- .../jbct/parser/Java25ParserTest.java | 35 ++-- .../jbct/maven/PackageSlicesMojo.java | 35 ++-- .../org/pragmatica/jbct/maven/ScoreMojo.java | 85 ++++++++ .../pragmatica/jbct/slice/SliceProcessor.java | 4 +- .../jbct/slice/model/KeyExtractorInfo.java | 12 -- .../slice/routing/ErrorPatternConfig.java | 18 +- .../jbct/slice/routing/RouteConfig.java | 18 +- 21 files changed, 689 insertions(+), 156 deletions(-) create mode 100644 jbct-cli/src/main/java/org/pragmatica/jbct/cli/ScoreCommand.java create mode 100644 jbct-core/src/main/java/org/pragmatica/jbct/score/RuleCategoryMapping.java create mode 100644 jbct-core/src/main/java/org/pragmatica/jbct/score/ScoreCalculator.java create mode 100644 jbct-core/src/main/java/org/pragmatica/jbct/score/ScoreCategory.java create mode 100644 jbct-core/src/main/java/org/pragmatica/jbct/score/ScoreResult.java create mode 100644 jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/ScoreMojo.java diff --git a/jbct-cli/src/main/java/org/pragmatica/jbct/cli/InitCommand.java b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/InitCommand.java index 7b9313d..ecf2311 100644 --- a/jbct-cli/src/main/java/org/pragmatica/jbct/cli/InitCommand.java +++ b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/InitCommand.java @@ -104,7 +104,7 @@ public Integer call() { if (!Files.exists(projectDir)) { try{ Files.createDirectories(projectDir); - } catch (Exception e) { + } catch (java.io.IOException e) { System.err.println("Error: Failed to create directory: " + e.getMessage()); return 1; } diff --git a/jbct-cli/src/main/java/org/pragmatica/jbct/cli/ScoreCommand.java b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/ScoreCommand.java new file mode 100644 index 0000000..aa5b90b --- /dev/null +++ b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/ScoreCommand.java @@ -0,0 +1,195 @@ +package org.pragmatica.jbct.cli; + +import org.pragmatica.jbct.config.ConfigLoader; +import org.pragmatica.jbct.config.JbctConfig; +import org.pragmatica.jbct.lint.Diagnostic; +import org.pragmatica.jbct.lint.JbctLinter; +import org.pragmatica.jbct.lint.LintContext; +import org.pragmatica.jbct.score.ScoreCalculator; +import org.pragmatica.jbct.score.ScoreCategory; +import org.pragmatica.jbct.score.ScoreResult; +import org.pragmatica.jbct.shared.FileCollector; +import org.pragmatica.jbct.shared.SourceFile; +import org.pragmatica.lang.Option; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; + +import picocli.CommandLine.Command; +import picocli.CommandLine.Parameters; + +/** + * Score command for JBCT compliance scoring. + */ +@Command( + name = "score", + description = "Calculate JBCT compliance score", + mixinStandardHelpOptions = true) +public class ScoreCommand implements Callable { + @Parameters( + paramLabel = "", + description = "Files or directories to score", + arity = "1..*") + List paths; + + @picocli.CommandLine.Option( + names = {"--format", "-f"}, + description = "Output format: terminal, json, badge", + defaultValue = "terminal") + String format; + + @picocli.CommandLine.Option( + names = {"--baseline", "-b"}, + description = "Minimum acceptable score (fails if below)") + Integer baseline; + + @picocli.CommandLine.Option( + names = {"--config"}, + description = "Path to configuration file") + Path configPath; + + @Override + public Integer call() { + var config = ConfigLoader.load(Option.option(configPath), Option.none()); + var context = createContext(config); + var linter = JbctLinter.jbctLinter(context); + var filesToProcess = collectJavaFiles(); + + if (filesToProcess.isEmpty()) { + System.err.println("No Java files found"); + return 1; + } + + var diagnostics = lintFiles(filesToProcess, linter); + var score = ScoreCalculator.calculate(diagnostics, filesToProcess.size()); + + outputScore(score); + + if (baseline != null && score.overall() < baseline) { + System.err.println("\nScore " + score.overall() + " below baseline " + baseline); + return 1; + } + + return 0; + } + + private LintContext createContext(JbctConfig jbctConfig) { + return LintContext.defaultContext() + .withConfig(jbctConfig.lint()) + .withBusinessPackages(jbctConfig.businessPackages()); + } + + private List collectJavaFiles() { + return FileCollector.collectJavaFiles(paths, System.err::println); + } + + private List lintFiles(List files, JbctLinter linter) { + var diagnostics = new ArrayList(); + + for (var file : files) { + SourceFile.sourceFile(file) + .flatMap(linter::lint) + .onSuccess(diagnostics::addAll) + .onFailure(cause -> System.err.println(" ✗ " + file + ": " + cause.message())); + } + + return diagnostics; + } + + private void outputScore(ScoreResult score) { + switch (format.toLowerCase()) { + case "json" -> outputJson(score); + case "badge" -> outputBadge(score); + default -> outputTerminal(score); + } + } + + private void outputTerminal(ScoreResult score) { + System.out.println("╔═══════════════════════════════════════════════════╗"); + System.out.printf("║ JBCT COMPLIANCE SCORE: %d/100 ║%n", score.overall()); + System.out.println("╠═══════════════════════════════════════════════════╣"); + + for (var category : ScoreCategory.values()) { + var categoryScore = score.breakdown() + .get(category); + var percent = categoryScore.score(); + var bar = createProgressBar(percent); + System.out.printf("║ %-18s %s %3d%% ║%n", + category.name() + .replace('_', ' '), + bar, + percent); + } + + System.out.println("╚═══════════════════════════════════════════════════╝"); + } + + private String createProgressBar(int percent) { + var filled = percent / 5; // 20 chars = 100% + var empty = 20 - filled; + return "█".repeat(filled) + "░".repeat(empty); + } + + private void outputJson(ScoreResult score) { + System.out.println("{"); + System.out.printf(" \"score\": %d,%n", score.overall()); + System.out.println(" \"breakdown\": {"); + + var categories = ScoreCategory.values(); + for (int i = 0; i < categories.length; i++) { + var category = categories[i]; + var categoryScore = score.breakdown() + .get(category); + System.out.printf(" \"%s\": %d%s%n", + category.name() + .toLowerCase(), + categoryScore.score(), + i < categories.length - 1 + ? "," + : ""); + } + + System.out.println(" },"); + System.out.printf(" \"filesAnalyzed\": %d%n", score.filesAnalyzed()); + System.out.println("}"); + } + + private void outputBadge(ScoreResult score) { + var color = score.overall() >= 90 + ? "brightgreen" + : score.overall() >= 75 + ? "green" + : score.overall() >= 60 + ? "yellow" + : score.overall() >= 50 + ? "orange" + : "red"; + + var svg = """ + + + + + + + + + + + + + + + JBCT + JBCT + %d/100 + %d/100 + + + """.formatted(color, score.overall(), score.overall()); + + System.out.println(svg); + } +} diff --git a/jbct-cli/src/main/java/org/pragmatica/jbct/cli/Version.java b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/Version.java index 9d0c830..3cbedfc 100644 --- a/jbct-cli/src/main/java/org/pragmatica/jbct/cli/Version.java +++ b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/Version.java @@ -3,10 +3,14 @@ import java.io.IOException; import java.util.Properties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + /** * Provides version information for JBCT CLI. */ public final class Version { + private static final Logger LOG = LoggerFactory.getLogger(Version.class); private static final String VERSION; static { @@ -15,7 +19,9 @@ public final class Version { if (is != null) { props.load(is); } - } catch (IOException _) {} + } catch (IOException e) { + LOG.debug("Failed to load version properties: {}", e.getMessage()); + } VERSION = props.getProperty("version", "unknown"); } diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/config/ConfigLoader.java b/jbct-core/src/main/java/org/pragmatica/jbct/config/ConfigLoader.java index 0a6ef09..1e96b97 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/config/ConfigLoader.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/config/ConfigLoader.java @@ -71,16 +71,17 @@ static Option loadFromFile(Path path) { * Find the project config file, searching up the directory tree. */ static Option findProjectConfig(Path startDir) { - var dir = startDir.toAbsolutePath() - .normalize(); - while (dir != null) { - var configPath = dir.resolve(PROJECT_CONFIG_NAME); - if (Files.exists(configPath) && Files.isRegularFile(configPath)) { - return Option.option(configPath); - } - dir = dir.getParent(); - } - return Option.none(); + return findProjectConfigRecursive(Option.some(startDir.toAbsolutePath() + .normalize())); + } + + private static Option findProjectConfigRecursive(Option dirOpt) { + return dirOpt.flatMap(dir -> { + var configPath = dir.resolve(PROJECT_CONFIG_NAME); + return Files.exists(configPath) && Files.isRegularFile(configPath) + ? Option.some(configPath) + : findProjectConfigRecursive(Option.option(dir.getParent())); + }); } /** diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/config/JbctConfig.java b/jbct-core/src/main/java/org/pragmatica/jbct/config/JbctConfig.java index db0fb6b..19b925f 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/config/JbctConfig.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/config/JbctConfig.java @@ -111,41 +111,34 @@ public static JbctConfig fromToml(TomlDocument toml) { * Merge this config with another, with other taking precedence. */ public JbctConfig merge(Option other) { - return other.map(o -> { - // Merge formatter config (use other if different from default) - var mergedFormatter = this.formatter; - if (!o.formatter.equals(FormatterConfig.DEFAULT)) { - mergedFormatter = o.formatter; - } - // Merge lint config (use other if different from default) - var mergedLint = this.lint; - if (!o.lint.equals(LintConfig.DEFAULT)) { - mergedLint = o.lint; - } - // Merge source directories (use other if not default) - var mergedSourceDirs = this.sourceDirectories; - if (!o.sourceDirectories.equals(List.of("src/main/java"))) { - mergedSourceDirs = o.sourceDirectories; - } - // Merge business packages (use other if not default) - var mergedBusinessPackages = this.businessPackages; - if (!o.businessPackages.equals(List.of("**.usecase.**", "**.domain.**"))) { - mergedBusinessPackages = o.businessPackages; - } - // Merge slice packages (use other if not empty) - var mergedSlicePackages = this.slicePackages; - if (!o.slicePackages.isEmpty()) { - mergedSlicePackages = o.slicePackages; - } - return jbctConfig(mergedFormatter, - mergedLint, - mergedSourceDirs, - mergedBusinessPackages, - mergedSlicePackages); - }) + return other.map(this::mergeWith) .or(this); } + private JbctConfig mergeWith(JbctConfig other) { + // Merge formatter config (use other if different from default) + var mergedFormatter = other.formatter.equals(FormatterConfig.DEFAULT) + ? this.formatter + : other.formatter; + // Merge lint config (use other if different from default) + var mergedLint = other.lint.equals(LintConfig.DEFAULT) + ? this.lint + : other.lint; + // Merge source directories (use other if not default) + var mergedSourceDirs = other.sourceDirectories.equals(List.of("src/main/java")) + ? this.sourceDirectories + : other.sourceDirectories; + // Merge business packages (use other if not default) + var mergedBusinessPackages = other.businessPackages.equals(List.of("**.usecase.**", "**.domain.**")) + ? this.businessPackages + : other.businessPackages; + // Merge slice packages (use other if not empty) + var mergedSlicePackages = other.slicePackages.isEmpty() + ? this.slicePackages + : other.slicePackages; + return jbctConfig(mergedFormatter, mergedLint, mergedSourceDirs, mergedBusinessPackages, mergedSlicePackages); + } + /** * Generate TOML representation of this config. */ diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectValidator.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectValidator.java index 53a73c1..542099d 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectValidator.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectValidator.java @@ -85,15 +85,11 @@ private PartialResult checkSliceManifests() { var errors = new ArrayList(); for (var manifestFile : manifestFiles) { loadProperties(manifestFile) - .fold(cause -> { - errors.add("Failed to read " + manifestFile.getFileName() + ": " + cause.message()); - return null; - }, - props -> { - checkRequired(props, "slice.interface", errors); - checkRequired(props, "slice.artifactId", errors); - return null; - }); + .onFailure(cause -> errors.add("Failed to read " + manifestFile.getFileName() + ": " + cause.message())) + .onSuccess(props -> { + checkRequired(props, "slice.interface", errors); + checkRequired(props, "slice.artifactId", errors); + }); } return PartialResult.partialResult(errors, List.of()); } catch (Exception e) { diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/score/RuleCategoryMapping.java b/jbct-core/src/main/java/org/pragmatica/jbct/score/RuleCategoryMapping.java new file mode 100644 index 0000000..55dfd27 --- /dev/null +++ b/jbct-core/src/main/java/org/pragmatica/jbct/score/RuleCategoryMapping.java @@ -0,0 +1,87 @@ +package org.pragmatica.jbct.score; + +import java.util.Map; + +/** + * Maps lint rule IDs to scoring categories. + */ +public final class RuleCategoryMapping { + private static final Map MAPPING = Map.ofEntries(// Return Types (25%) + Map.entry("JBCT-RET-01", ScoreCategory.RETURN_TYPES), + // Four Return Kinds + Map.entry("JBCT-RET-02", ScoreCategory.RETURN_TYPES), + // No Void + Map.entry("JBCT-RET-03", ScoreCategory.RETURN_TYPES), + // No Promise> + Map.entry("JBCT-NEST-02", ScoreCategory.RETURN_TYPES), + // No nested wrappers + Map.entry("JBCT-RES-01", ScoreCategory.RETURN_TYPES), + // Always-success Result + // Null Safety (20%) + Map.entry("JBCT-NULL-01", ScoreCategory.NULL_SAFETY), + // No null return + Map.entry("JBCT-NULL-02", ScoreCategory.NULL_SAFETY), + // No nullable parameters + // Exception Hygiene (20%) + Map.entry("JBCT-EXC-01", ScoreCategory.EXCEPTION_HYGIENE), + // No business exceptions + Map.entry("JBCT-EXC-02", ScoreCategory.EXCEPTION_HYGIENE), + // No orElseThrow + // Pattern Purity (15%) + Map.entry("JBCT-PAT-01", ScoreCategory.PATTERN_PURITY), + // Pattern mixing + Map.entry("JBCT-NEST-01", ScoreCategory.PATTERN_PURITY), + // Nested operations + Map.entry("JBCT-CHAIN-01", ScoreCategory.PATTERN_PURITY), + // Chain length + // Factory Methods (10%) + Map.entry("JBCT-VO-01", ScoreCategory.FACTORY_METHODS), + // Factory naming + Map.entry("JBCT-VO-02", ScoreCategory.FACTORY_METHODS), + // Constructor bypass + Map.entry("JBCT-VO-03", ScoreCategory.FACTORY_METHODS), + // Constructor reference + Map.entry("JBCT-VO-04", ScoreCategory.FACTORY_METHODS), + // Nested record factory + Map.entry("JBCT-ERR-01", ScoreCategory.FACTORY_METHODS), + // Sealed error types + // Lambda Compliance (10%) + Map.entry("JBCT-LAM-01", ScoreCategory.LAMBDA_COMPLIANCE), + // Lambda braces + Map.entry("JBCT-LAM-02", ScoreCategory.LAMBDA_COMPLIANCE), + // Lambda complexity + Map.entry("JBCT-LAM-03", ScoreCategory.LAMBDA_COMPLIANCE), + // Lambda ternary + Map.entry("JBCT-LAM-04", ScoreCategory.LAMBDA_COMPLIANCE), + // Method reference preference + // Cross-cutting (distribute to appropriate categories) + Map.entry("JBCT-NAME-01", ScoreCategory.FACTORY_METHODS), + // Acronym naming + Map.entry("JBCT-NAME-02", ScoreCategory.EXCEPTION_HYGIENE), + // Fluent failure + Map.entry("JBCT-STATIC-01", ScoreCategory.LAMBDA_COMPLIANCE), + // Static imports + Map.entry("JBCT-IMPORT-01", ScoreCategory.PATTERN_PURITY), + // Import ordering + Map.entry("JBCT-FQN-01", ScoreCategory.PATTERN_PURITY), + // Fully qualified names + Map.entry("JBCT-DOMAIN-01", ScoreCategory.PATTERN_PURITY), + // Domain I/O separation + Map.entry("JBCT-LOOP-01", ScoreCategory.PATTERN_PURITY), + // Raw loops + Map.entry("JBCT-LOG-01", ScoreCategory.PATTERN_PURITY), + // Conditional logging + Map.entry("JBCT-LOG-02", ScoreCategory.PATTERN_PURITY), + // Logger parameters + Map.entry("JBCT-PARSE-01", ScoreCategory.FACTORY_METHODS)); + + private RuleCategoryMapping() {} + + public static ScoreCategory categoryFor(String ruleId) { + return MAPPING.getOrDefault(ruleId, ScoreCategory.PATTERN_PURITY); + } + + public static Map mapping() { + return MAPPING; + } +} diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/score/ScoreCalculator.java b/jbct-core/src/main/java/org/pragmatica/jbct/score/ScoreCalculator.java new file mode 100644 index 0000000..8a5de6b --- /dev/null +++ b/jbct-core/src/main/java/org/pragmatica/jbct/score/ScoreCalculator.java @@ -0,0 +1,95 @@ +package org.pragmatica.jbct.score; + +import org.pragmatica.jbct.lint.Diagnostic; +import org.pragmatica.jbct.lint.DiagnosticSeverity; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +/** + * Calculates JBCT compliance scores using density + severity weighting. + * + * Formula: + * weighted_violations = Σ(count[severity] × multiplier[severity]) + * error: × 2.5 + * warning: × 1.0 + * info: × 0.3 + * + * category_score = 100 × (1 - weighted_violations / checkpoints) + * overall_score = Σ(category_score[i] × weight[i]) + */ +public final class ScoreCalculator { + private static final double ERROR_MULTIPLIER = 2.5; + private static final double WARNING_MULTIPLIER = 1.0; + private static final double INFO_MULTIPLIER = 0.3; + + private ScoreCalculator() {} + + /** + * Calculate JBCT score from lint diagnostics. + */ + public static ScoreResult calculate(List diagnostics, int filesAnalyzed) { + var categoryViolations = groupByCategory(diagnostics); + var categoryCheckpoints = countCheckpoints(diagnostics); + var breakdown = new EnumMap(ScoreCategory.class); + for (var category : ScoreCategory.values()) { + var violations = categoryViolations.getOrDefault(category, List.of()); + var checkpoints = categoryCheckpoints.getOrDefault(category, 1); + // Avoid division by zero + var weightedViolations = calculateWeightedViolations(violations); + var score = calculateCategoryScore(weightedViolations, checkpoints); + breakdown.put(category, + new ScoreResult.CategoryScore(score, checkpoints, violations.size(), weightedViolations)); + } + var overall = calculateOverallScore(breakdown); + return new ScoreResult(overall, breakdown, filesAnalyzed); + } + + private static Map> groupByCategory(List diagnostics) { + return diagnostics.stream() + .collect(java.util.stream.Collectors.groupingBy(d -> RuleCategoryMapping.categoryFor(d.ruleId()))); + } + + private static Map countCheckpoints(List diagnostics) { + // For now, use violation count as proxy for checkpoints + // TODO: Get actual checkpoint counts from linter + var checkpointMap = new EnumMap(ScoreCategory.class); + for (var category : ScoreCategory.values()) { + var violations = diagnostics.stream() + .filter(d -> RuleCategoryMapping.categoryFor(d.ruleId()) == category) + .count(); + // Estimate: at least violations + 10% (so perfect score is possible) + checkpointMap.put(category, (int)(violations * 1.1 + 10)); + } + return checkpointMap; + } + + private static double calculateWeightedViolations(List violations) { + return violations.stream() + .mapToDouble(d -> switch (d.severity()) { + case ERROR -> ERROR_MULTIPLIER; + case WARNING -> WARNING_MULTIPLIER; + case INFO -> INFO_MULTIPLIER; + }) + .sum(); + } + + private static int calculateCategoryScore(double weightedViolations, int checkpoints) { + if (checkpoints == 0) { + return 100; + } + var score = 100.0 * (1.0 - weightedViolations / checkpoints); + return Math.max(0, + Math.min(100, (int) Math.round(score))); + } + + private static int calculateOverallScore(Map breakdown) { + var weightedSum = 0.0; + for (var category : ScoreCategory.values()) { + var categoryScore = breakdown.get(category); + weightedSum += categoryScore.score() * category.weightFraction(); + } + return (int) Math.round(weightedSum); + } +} diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/score/ScoreCategory.java b/jbct-core/src/main/java/org/pragmatica/jbct/score/ScoreCategory.java new file mode 100644 index 0000000..712b30b --- /dev/null +++ b/jbct-core/src/main/java/org/pragmatica/jbct/score/ScoreCategory.java @@ -0,0 +1,48 @@ +package org.pragmatica.jbct.score; +/** + * JBCT score categories with their weights. + * + * Categories track compliance across different JBCT principles. + */ +public enum ScoreCategory { + /** + * Return Types (25%) - Correct use of four return kinds. + */ + RETURN_TYPES(25.0), + /** + * Null Safety (20%) - No null returns, no nullable parameters. + */ + NULL_SAFETY(20.0), + /** + * Exception Hygiene (20%) - No business exceptions, proper error handling. + */ + EXCEPTION_HYGIENE(20.0), + /** + * Pattern Purity (15%) - Single pattern per function, no mixing. + */ + PATTERN_PURITY(15.0), + /** + * Factory Methods (10%) - Value object factories, naming conventions. + */ + FACTORY_METHODS(10.0), + /** + * Lambda Compliance (10%) - Simple lambdas, no complex logic. + */ + LAMBDA_COMPLIANCE(10.0); + private final double weight; + ScoreCategory(double weight) { + this.weight = weight; + } + /** + * Get the weight of this category (0-100). + */ + public double weight() { + return weight; + } + /** + * Get the weight as a fraction (0.0-1.0). + */ + public double weightFraction() { + return weight / 100.0; + } +} diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/score/ScoreResult.java b/jbct-core/src/main/java/org/pragmatica/jbct/score/ScoreResult.java new file mode 100644 index 0000000..b57c97e --- /dev/null +++ b/jbct-core/src/main/java/org/pragmatica/jbct/score/ScoreResult.java @@ -0,0 +1,18 @@ +package org.pragmatica.jbct.score; + +import java.util.Map; + +/** + * Immutable result of JBCT compliance scoring. + */ +public record ScoreResult(int overall, + Map breakdown, + int filesAnalyzed) { + /** + * Score for a single category. + */ + public record CategoryScore(int score, + int checkpoints, + int violations, + double weightedViolations) {} +} diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceManifest.java b/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceManifest.java index bc4ed03..002415b 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceManifest.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceManifest.java @@ -77,30 +77,35 @@ public static Result load(InputStream input) { } private static Result fromProperties(Properties props) { - var sliceName = props.getProperty("slice.name"); - if (sliceName == null || sliceName.isEmpty()) { - return Causes.cause("Missing required property: slice.name") - .result(); - } - var artifactSuffix = props.getProperty("slice.artifactSuffix", ""); - var slicePackage = props.getProperty("slice.package", ""); - var implClasses = parseList(props.getProperty("impl.classes", "")); - var requestClasses = parseList(props.getProperty("request.classes", "")); - var responseClasses = parseList(props.getProperty("response.classes", "")); - var baseArtifact = props.getProperty("base.artifact", ""); - var implArtifactId = props.getProperty("slice.artifactId", ""); - var dependencies = parseDependencies(props); - var configFile = props.getProperty("config.file", ""); - return Result.success(new SliceManifest(sliceName, - artifactSuffix, - slicePackage, - implClasses, - requestClasses, - responseClasses, - baseArtifact, - implArtifactId, - dependencies, - configFile)); + return org.pragmatica.lang.Option.option(props.getProperty("slice.name")) + .filter(s -> !s.isEmpty()) + .toResult(Causes.cause("Missing required property: slice.name")) + .map(sliceName -> { + var artifactSuffix = getPropertyOrEmpty(props, "slice.artifactSuffix"); + var slicePackage = getPropertyOrEmpty(props, "slice.package"); + var implClasses = parseList(getPropertyOrEmpty(props, "impl.classes")); + var requestClasses = parseList(getPropertyOrEmpty(props, "request.classes")); + var responseClasses = parseList(getPropertyOrEmpty(props, "response.classes")); + var baseArtifact = getPropertyOrEmpty(props, "base.artifact"); + var implArtifactId = getPropertyOrEmpty(props, "slice.artifactId"); + var dependencies = parseDependencies(props); + var configFile = getPropertyOrEmpty(props, "config.file"); + return new SliceManifest(sliceName, + artifactSuffix, + slicePackage, + implClasses, + requestClasses, + responseClasses, + baseArtifact, + implArtifactId, + dependencies, + configFile); + }); + } + + private static String getPropertyOrEmpty(Properties props, String key) { + return org.pragmatica.lang.Option.option(props.getProperty(key)) + .or(""); } private static final Logger LOG = LoggerFactory.getLogger(SliceManifest.class); @@ -129,13 +134,13 @@ private static List parseDependencies(Properties props) { } private static List parseList(String value) { - if (value == null || value.isEmpty()) { - return List.of(); - } - return Arrays.stream(value.split(",")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .toList(); + return org.pragmatica.lang.Option.option(value) + .filter(s -> !s.isEmpty()) + .map(s -> Arrays.stream(s.split(",")) + .map(String::trim) + .filter(part -> !part.isEmpty()) + .toList()) + .or(List.of()); } /** diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/upgrade/JarInstaller.java b/jbct-core/src/main/java/org/pragmatica/jbct/upgrade/JarInstaller.java index d329294..8c42c31 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/upgrade/JarInstaller.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/upgrade/JarInstaller.java @@ -45,8 +45,9 @@ public static JarInstaller jarInstaller() { * Get the default installation path (~/.jbct/lib/jbct.jar). */ public static Path defaultInstallPath() { - var userHome = System.getProperty("user.home"); - return Path.of(userHome, DEFAULT_INSTALL_DIR, LIB_DIR, JAR_NAME); + return Option.option(System.getProperty("user.home")) + .map(userHome -> Path.of(userHome, DEFAULT_INSTALL_DIR, LIB_DIR, JAR_NAME)) + .or(() -> Path.of(System.getProperty("java.io.tmpdir"), DEFAULT_INSTALL_DIR, LIB_DIR, JAR_NAME)); } /** diff --git a/jbct-core/src/test/java/org/pragmatica/jbct/format/cst/CstFormatterTest.java b/jbct-core/src/test/java/org/pragmatica/jbct/format/cst/CstFormatterTest.java index a61c273..4d41f0d 100644 --- a/jbct-core/src/test/java/org/pragmatica/jbct/format/cst/CstFormatterTest.java +++ b/jbct-core/src/test/java/org/pragmatica/jbct/format/cst/CstFormatterTest.java @@ -619,9 +619,12 @@ void cstFormatter_isIdempotent_afterMultiplePasses(String fileName) throws IOExc var content = Files.readString(path); var source = new SourceFile(path, content); // Format once - var firstPass = formatter.format(source) - .onFailure(cause -> fail("First format failed for " + fileName + ": " + cause.message())) - .unwrap(); + var firstPassResult = formatter.format(source) + .onFailure(cause -> fail("First format failed for " + fileName + ": " + cause.message())); + if (firstPassResult.isFailure()) { + return; // Already failed above + } + var firstPass = firstPassResult.or(source); // Format 10 more times and verify no growth var current = firstPass; int firstLength = firstPass.content() @@ -631,9 +634,12 @@ void cstFormatter_isIdempotent_afterMultiplePasses(String fileName) throws IOExc for (int i = 2; i <= 10; i++) { final var toFormat = current; final int passNum = i; - current = formatter.format(toFormat) - .onFailure(cause -> fail("Format pass " + passNum + " failed for " + fileName + ": " + cause.message())) - .unwrap(); + var currentResult = formatter.format(toFormat) + .onFailure(cause -> fail("Format pass " + passNum + " failed for " + fileName + ": " + cause.message())); + if (currentResult.isFailure()) { + return; // Already failed above + } + current = currentResult.or(toFormat); int currentLength = current.content() .length(); int currentLines = current.content() diff --git a/jbct-core/src/test/java/org/pragmatica/jbct/lint/cst/CstLinterTest.java b/jbct-core/src/test/java/org/pragmatica/jbct/lint/cst/CstLinterTest.java index c4fce8f..85c2836 100644 --- a/jbct-core/src/test/java/org/pragmatica/jbct/lint/cst/CstLinterTest.java +++ b/jbct-core/src/test/java/org/pragmatica/jbct/lint/cst/CstLinterTest.java @@ -33,7 +33,8 @@ private List lint(String source) { var sourceFile = SourceFile.sourceFile(Path.of("Test.java"), source); var result = linter.lint(sourceFile); assertTrue(result.isSuccess(), () -> "Parse failed: " + result); - return result.unwrap(); + return result.onFailure(cause -> fail("Parse failed: " + cause.message())) + .or(List.of()); } private void assertHasRule(List diagnostics, String ruleId) { diff --git a/jbct-core/src/test/java/org/pragmatica/jbct/parser/Java25ParserTest.java b/jbct-core/src/test/java/org/pragmatica/jbct/parser/Java25ParserTest.java index 6b20ae5..0cf7ea8 100644 --- a/jbct-core/src/test/java/org/pragmatica/jbct/parser/Java25ParserTest.java +++ b/jbct-core/src/test/java/org/pragmatica/jbct/parser/Java25ParserTest.java @@ -16,10 +16,9 @@ class Java25ParserTest { void parseEmptyClass() { var result = parser.parse("class Foo { }"); assertTrue(result.isSuccess(), () -> "Failed: " + result); - var cst = result.unwrap(); - assertEquals("CompilationUnit", - cst.rule() - .name()); + result.onSuccess(cst -> assertEquals("CompilationUnit", + cst.rule() + .name())); } @Test @@ -100,25 +99,27 @@ void test() { void cstPreservesSourceLocation() { var result = parser.parse("class Foo { }"); assertTrue(result.isSuccess()); - var cst = result.unwrap(); - var span = cst.span(); - assertEquals(1, - span.start() - .line()); - assertEquals(1, - span.start() - .column()); + result.onSuccess(cst -> { + var span = cst.span(); + assertEquals(1, + span.start() + .line()); + assertEquals(1, + span.start() + .column()); + }); } @Test void cstHasChildren() { var result = parser.parse("class Foo { int x; }"); assertTrue(result.isSuccess()); - var cst = result.unwrap(); - assertTrue(cst instanceof CstNode.NonTerminal); - var root = (CstNode.NonTerminal) cst; - assertFalse(root.children() - .isEmpty()); + result.onSuccess(cst -> { + assertTrue(cst instanceof CstNode.NonTerminal); + var root = (CstNode.NonTerminal) cst; + assertFalse(root.children() + .isEmpty()); + }); } @Test diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java index 0536e66..6f1b089 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java @@ -209,11 +209,11 @@ private boolean isSliceDependency(Artifact artifact) { } } - private java.util.Properties readFirstSliceManifest(Artifact artifact) { + private java.util.Optional readFirstSliceManifest(Artifact artifact) { var file = artifact.getFile(); if (file == null || !file.exists() || !file.getName() .endsWith(".jar")) { - return null; + return java.util.Optional.empty(); } try (var jar = new JarFile(file)) { var entries = jar.entries(); @@ -226,13 +226,13 @@ private java.util.Properties readFirstSliceManifest(Artifact artifact) { try (var stream = jar.getInputStream(entry)) { props.load(stream); } - return props; + return java.util.Optional.of(props); } } - return null; + return java.util.Optional.empty(); } catch (IOException e) { getLog().debug("Could not read JAR: " + file + " - " + e.getMessage()); - return null; + return java.util.Optional.empty(); } } @@ -242,17 +242,20 @@ private ArtifactInfo toArtifactInfo(Artifact artifact) { private ArtifactInfo toSliceArtifactInfo(Artifact artifact) { // Read slice artifact from manifest (has correct naming: groupId:artifactId-sliceName) - var props = readFirstSliceManifest(artifact); - if (props != null) { - var sliceArtifactId = props.getProperty("slice.artifactId"); - var baseArtifact = props.getProperty("base.artifact"); - if (sliceArtifactId != null && baseArtifact != null && baseArtifact.contains(":")) { - var groupId = baseArtifact.split(":") [0]; - return new ArtifactInfo(groupId, sliceArtifactId, toSemverRange(artifact.getVersion())); - } - } - // Fallback to Maven artifact - return toArtifactInfo(artifact); + return readFirstSliceManifest(artifact) + .flatMap(props -> { + var sliceArtifactId = props.getProperty("slice.artifactId"); + var baseArtifact = props.getProperty("base.artifact"); + if (sliceArtifactId != null && baseArtifact != null && baseArtifact.contains(":")) { + var groupId = baseArtifact.split(":")[0]; + return java.util.Optional.of(new ArtifactInfo(groupId, + sliceArtifactId, + toSemverRange(artifact.getVersion()))); + } + return java.util.Optional.empty(); + }) + // Fallback to Maven artifact + .orElseGet(() -> toArtifactInfo(artifact)); } private String toSemverRange(String version) { diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/ScoreMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/ScoreMojo.java new file mode 100644 index 0000000..5394724 --- /dev/null +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/ScoreMojo.java @@ -0,0 +1,85 @@ +package org.pragmatica.jbct.maven; + +import org.pragmatica.jbct.lint.Diagnostic; +import org.pragmatica.jbct.lint.JbctLinter; +import org.pragmatica.jbct.score.ScoreCalculator; +import org.pragmatica.jbct.score.ScoreCategory; +import org.pragmatica.jbct.score.ScoreResult; +import org.pragmatica.jbct.shared.SourceFile; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; + +/** + * Maven goal for calculating JBCT compliance score. + */ +@Mojo(name = "score", defaultPhase = LifecyclePhase.VERIFY) +public class ScoreMojo extends AbstractJbctMojo { + @Parameter(property = "jbct.score.baseline") + Integer baseline; + + @Override + public void execute() throws MojoExecutionException, MojoFailureException { + if (shouldSkip("score")) { + return; + } + var jbctConfig = loadConfig(); + var context = createLintContext(jbctConfig); + var linter = JbctLinter.jbctLinter(context); + var filesToProcess = collectJavaFiles(); + if (filesToProcess.isEmpty()) { + getLog().info("No Java files found."); + return; + } + getLog().info("Scoring " + filesToProcess.size() + " Java file(s)"); + var allDiagnostics = lintFiles(filesToProcess, linter); + var score = ScoreCalculator.calculate(allDiagnostics, filesToProcess.size()); + outputScore(score); + if (baseline != null && score.overall() < baseline) { + throw new MojoFailureException("Score " + score.overall() + " below baseline " + baseline); + } + } + + private List lintFiles(List files, JbctLinter linter) { + var diagnostics = new ArrayList(); + for (var file : files) { + SourceFile.sourceFile(file) + .flatMap(linter::lint) + .onSuccess(diagnostics::addAll) + .onFailure(cause -> getLog().error("Parse error in " + file + ": " + cause.message())); + } + return diagnostics; + } + + private void outputScore(ScoreResult score) { + getLog().info("╔═══════════════════════════════════════════════════╗"); + getLog().info(String.format("║ JBCT COMPLIANCE SCORE: %d/100 ║", score.overall())); + getLog().info("╠═══════════════════════════════════════════════════╣"); + for (var category : ScoreCategory.values()) { + var categoryScore = score.breakdown() + .get(category); + var percent = categoryScore.score(); + var bar = createProgressBar(percent); + getLog().info(String.format("║ %-18s %s %3d%% ║", + category.name() + .replace('_', ' '), + bar, + percent)); + } + getLog().info("╚═══════════════════════════════════════════════════╝"); + } + + private String createProgressBar(int percent) { + var filled = percent / 5; + // 20 chars = 100% + var empty = 20 - filled; + return "█".repeat(filled) + "░".repeat(empty); + } +} diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java index d4b1cbc..388c961 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/SliceProcessor.java @@ -44,8 +44,8 @@ public class SliceProcessor extends AbstractProcessor { private DependencyVersionResolver versionResolver; private RouteSourceGenerator routeGenerator; private ErrorTypeDiscovery errorDiscovery; - private final java.util.Map packageToSlice = new java.util.HashMap<>(); - private final java.util.Set routeServiceEntries = new java.util.LinkedHashSet<>(); + private final java.util.Map packageToSlice = new java.util.concurrent.ConcurrentHashMap<>(); + private final java.util.Set routeServiceEntries = java.util.Collections.synchronizedSet(new java.util.LinkedHashSet<>()); @Override public synchronized void init(javax.annotation.processing.ProcessingEnvironment processingEnv) { diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/model/KeyExtractorInfo.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/model/KeyExtractorInfo.java index 0ec2c47..cfd04a8 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/model/KeyExtractorInfo.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/model/KeyExtractorInfo.java @@ -14,18 +14,6 @@ public record KeyExtractorInfo(String keyType, String extractorExpression) { private static final Pattern JAVA_IDENTIFIER = Pattern.compile("^[a-zA-Z_$][a-zA-Z0-9_$]*$"); - /** - * Compact constructor validates all fields. - */ - public KeyExtractorInfo { - if (keyType == null || keyType.isEmpty()) { - throw new IllegalArgumentException("keyType cannot be null or empty"); - } - if (extractorExpression == null || extractorExpression.isEmpty()) { - throw new IllegalArgumentException("extractorExpression cannot be null or empty"); - } - } - /** * Create extractor for a single @Key-annotated field. * diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/ErrorPatternConfig.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/ErrorPatternConfig.java index bd3cd5d..04ba90a 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/ErrorPatternConfig.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/ErrorPatternConfig.java @@ -62,17 +62,19 @@ public static ErrorPatternConfig errorPatternConfig(int defaultStatus, * @return merged configuration */ public ErrorPatternConfig merge(Option other) { - return other.map(o -> { - var mergedDefault = o.defaultStatus != 500 - ? o.defaultStatus - : this.defaultStatus; - var mergedPatterns = mergePatterns(this.statusPatterns, o.statusPatterns); - var mergedExplicit = mergeMappings(this.explicitMappings, o.explicitMappings); - return errorPatternConfig(mergedDefault, mergedPatterns, mergedExplicit); - }) + return other.map(this::mergeWith) .or(this); } + private ErrorPatternConfig mergeWith(ErrorPatternConfig other) { + var mergedDefault = other.defaultStatus != 500 + ? other.defaultStatus + : this.defaultStatus; + var mergedPatterns = mergePatterns(this.statusPatterns, other.statusPatterns); + var mergedExplicit = mergeMappings(this.explicitMappings, other.explicitMappings); + return errorPatternConfig(mergedDefault, mergedPatterns, mergedExplicit); + } + private static Map> mergePatterns(Map> base, Map> overlay) { var merged = new HashMap<>(base); diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteConfig.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteConfig.java index 00dc7c3..e0070a3 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteConfig.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/routing/RouteConfig.java @@ -61,17 +61,19 @@ public static RouteConfig routeConfig(String prefix, * @return merged configuration */ public RouteConfig merge(Option other) { - return other.map(o -> { - var mergedPrefix = o.prefix.isEmpty() - ? this.prefix - : o.prefix; - var mergedRoutes = mergeRoutes(this.routes, o.routes); - var mergedErrors = this.errors.merge(Option.some(o.errors)); - return routeConfig(mergedPrefix, mergedRoutes, mergedErrors); - }) + return other.map(this::mergeWith) .or(this); } + private RouteConfig mergeWith(RouteConfig other) { + var mergedPrefix = other.prefix.isEmpty() + ? this.prefix + : other.prefix; + var mergedRoutes = mergeRoutes(this.routes, other.routes); + var mergedErrors = this.errors.merge(Option.some(other.errors)); + return routeConfig(mergedPrefix, mergedRoutes, mergedErrors); + } + private static Map mergeRoutes(Map base, Map overlay) { var merged = new HashMap<>(base); From 945d20bab6e772eb5a15ad792da545b6529505cc Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Sun, 1 Feb 2026 07:55:52 +0100 Subject: [PATCH 43/46] refactor: rename SecurityError types to use past tense per JBCT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PathTraversal → PathTraversalDetected - InvalidUrl → UrlRejected - UntrustedDomain → DomainRejected Also added versioning policy to CLAUDE.md: backward compatibility not guaranteed until 1.0. Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 2 +- CLAUDE.md | 4 ++++ .../jbct/shared/PathValidation.java | 8 +++---- .../pragmatica/jbct/shared/SecurityError.java | 23 ++++++++++--------- .../pragmatica/jbct/shared/UrlValidation.java | 10 ++++---- 5 files changed, 26 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5b8a22..cbd60af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,7 +48,7 @@ ## [0.5.0] - 2026-01-20 ### Added -- Security: `SecurityError` sealed interface with `PathTraversal`, `InvalidUrl`, `UntrustedDomain` error types +- Security: `SecurityError` sealed interface with `PathTraversalDetected`, `UrlRejected`, `DomainRejected` error types - Slice verify: dependency scope validation for Aether runtime libraries - `jbct:verify-slice` now fails if `org.pragmatica-lite` or `org.pragmatica-lite.aether` dependencies are not `provided` scope - Prevents accidental bundling of runtime libraries in slice JARs diff --git a/CLAUDE.md b/CLAUDE.md index 6ea3eff..9c31345 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,10 @@ Relevant RFCs: To invoke: Use the `/jbct` skill or spawn the `jbct-coder` agent via the Task tool. +## Versioning Policy + +Until release 1.0, backward compatibility is not guaranteed. Breaking changes are acceptable for JBCT compliance improvements. + ## Project Overview CLI tool and Maven plugin for JBCT (Java Backend Coding Technology) code formatting and linting. diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/shared/PathValidation.java b/jbct-core/src/main/java/org/pragmatica/jbct/shared/PathValidation.java index cf78d72..27167d2 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/shared/PathValidation.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/shared/PathValidation.java @@ -19,18 +19,18 @@ record unused() implements PathValidation {} */ static Result validateRelativePath(String relativePath, Path baseDir) { if (relativePath == null || relativePath.isBlank()) { - return SecurityError.PathTraversal.pathTraversal(relativePath, "path is null or blank") + return SecurityError.PathTraversalDetected.pathTraversalDetected(relativePath, "path is null or blank") .result(); } // Reject path traversal sequences if (relativePath.contains("..")) { - return SecurityError.PathTraversal.pathTraversal(relativePath, "contains '..' sequence") + return SecurityError.PathTraversalDetected.pathTraversalDetected(relativePath, "contains '..' sequence") .result(); } // Reject absolute paths var pathObj = Path.of(relativePath); if (pathObj.isAbsolute()) { - return SecurityError.PathTraversal.pathTraversal(relativePath, "absolute path not allowed") + return SecurityError.PathTraversalDetected.pathTraversalDetected(relativePath, "absolute path not allowed") .result(); } // Resolve and normalize the path @@ -41,7 +41,7 @@ static Result validateRelativePath(String relativePath, Path baseDir) { .toAbsolutePath(); // Verify the resolved path starts with base directory if (!resolved.startsWith(normalizedBase)) { - return SecurityError.PathTraversal.pathTraversal(relativePath, "path escapes base directory") + return SecurityError.PathTraversalDetected.pathTraversalDetected(relativePath, "path escapes base directory") .result(); } return Result.success(resolved); diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/shared/SecurityError.java b/jbct-core/src/main/java/org/pragmatica/jbct/shared/SecurityError.java index 16ee55f..f1abe3a 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/shared/SecurityError.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/shared/SecurityError.java @@ -7,11 +7,12 @@ /** * Security-related errors for path and URL validation. + * Named using past tense per JBCT error naming convention. */ public sealed interface SecurityError extends Cause { - record PathTraversal(String path, String reason) implements SecurityError { - public static PathTraversal pathTraversal(String path, String reason) { - return new PathTraversal(path, reason); + record PathTraversalDetected(String path, String reason) implements SecurityError { + public static PathTraversalDetected pathTraversalDetected(String path, String reason) { + return new PathTraversalDetected(path, reason); } @Override @@ -20,25 +21,25 @@ public String message() { } } - record InvalidUrl(String url, String reason) implements SecurityError { - public static InvalidUrl invalidUrl(String url, String reason) { - return new InvalidUrl(url, reason); + record UrlRejected(String url, String reason) implements SecurityError { + public static UrlRejected urlRejected(String url, String reason) { + return new UrlRejected(url, reason); } @Override public String message() { - return "Invalid URL: " + url + " (" + reason + ")"; + return "URL rejected: " + url + " (" + reason + ")"; } } - record UntrustedDomain(String url, String domain) implements SecurityError { - public static UntrustedDomain untrustedDomain(String url, String domain) { - return new UntrustedDomain(url, domain); + record DomainRejected(String url, String domain) implements SecurityError { + public static DomainRejected domainRejected(String url, String domain) { + return new DomainRejected(url, domain); } @Override public String message() { - return "Untrusted domain: " + domain + " in URL: " + url; + return "Domain rejected: " + domain + " in URL: " + url; } } } diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/shared/UrlValidation.java b/jbct-core/src/main/java/org/pragmatica/jbct/shared/UrlValidation.java index e175b50..8490cda 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/shared/UrlValidation.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/shared/UrlValidation.java @@ -24,32 +24,32 @@ record unused() implements UrlValidation {} */ static Result validateDownloadUrl(String url) { if (url == null || url.isBlank()) { - return SecurityError.InvalidUrl.invalidUrl(url, "URL is null or blank") + return SecurityError.UrlRejected.urlRejected(url, "URL is null or blank") .result(); } URI uri; try{ uri = URI.create(url); } catch (IllegalArgumentException e) { - return SecurityError.InvalidUrl.invalidUrl(url, + return SecurityError.UrlRejected.urlRejected(url, "malformed URL: " + e.getMessage()) .result(); } // Require HTTPS var scheme = uri.getScheme(); if (scheme == null || !scheme.equalsIgnoreCase("https")) { - return SecurityError.InvalidUrl.invalidUrl(url, "HTTPS required") + return SecurityError.UrlRejected.urlRejected(url, "HTTPS required") .result(); } // Validate domain against whitelist var host = uri.getHost(); if (host == null) { - return SecurityError.InvalidUrl.invalidUrl(url, "no host specified") + return SecurityError.UrlRejected.urlRejected(url, "no host specified") .result(); } var hostLower = host.toLowerCase(); if (!TRUSTED_DOMAINS.contains(hostLower)) { - return SecurityError.UntrustedDomain.untrustedDomain(url, hostLower) + return SecurityError.DomainRejected.domainRejected(url, hostLower) .result(); } return Result.success(uri); From 173acf8ce2b00fef3f8fe199d184f628b2dafa77 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Sun, 1 Feb 2026 13:53:49 +0100 Subject: [PATCH 44/46] fix: address all remaining JBCT compliance issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: - FormattingError: rename ParseError→ParseFailed, IoError→IoFailed - TestSlice: delete TestSliceImpl, use inline lambda factory - SliceProjectInitializer: extract lambdas to named methods - SliceManifest: extract buildManifest() method - SliceProcessorTest: replace orElseThrow() with get() Warnings: - InitCommand: use Option.option().or() for null checks - FormatCommand/LintCommand: replace AtomicInteger with int[] - FileCollector: return List.copyOf() for immutability - AiToolsUpdater: extract performUpdate() method - CstNodes: extract findAncestorInPath() method - JbctFormatterTest: use monadic assertions Co-Authored-By: Claude Opus 4.5 --- CROSS_CUTTING_REVIEW.md | 337 ++++++++++++++++++ .../pragmatica/jbct/cli/FormatCommand.java | 50 +-- .../org/pragmatica/jbct/cli/InitCommand.java | 29 +- .../org/pragmatica/jbct/cli/LintCommand.java | 30 +- .../jbct/format/FormattingError.java | 12 +- .../jbct/format/cst/CstFormatter.java | 6 +- .../jbct/init/SliceProjectInitializer.java | 22 +- .../org/pragmatica/jbct/parser/CstNodes.java | 18 +- .../pragmatica/jbct/shared/FileCollector.java | 4 +- .../pragmatica/jbct/slice/SliceManifest.java | 44 +-- .../jbct/update/AiToolsUpdater.java | 20 +- .../jbct/format/JbctFormatterTest.java | 61 ++-- .../java/com/example/testslice/TestSlice.java | 37 +- .../com/example/testslice/TestSliceImpl.java | 43 --- .../jbct/slice/SliceProcessorTest.java | 22 +- 15 files changed, 520 insertions(+), 215 deletions(-) create mode 100644 CROSS_CUTTING_REVIEW.md delete mode 100644 slice-processor-tests/src/main/java/com/example/testslice/TestSliceImpl.java diff --git a/CROSS_CUTTING_REVIEW.md b/CROSS_CUTTING_REVIEW.md new file mode 100644 index 0000000..847084d --- /dev/null +++ b/CROSS_CUTTING_REVIEW.md @@ -0,0 +1,337 @@ +# JBCT CLI - Cross-Cutting Concerns Review + +## Executive Summary +Review of jbct-cli codebase focusing on security, performance, logging, and error handling patterns. Overall compliance is **GOOD** with some notable patterns and a few items requiring attention. + +--- + +## Critical Issues + +### 1. CRITICAL: Unchecked `.unwrap()` Without Null Guard +**Severity:** CRITICAL +**Files:** +- `/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/DeploySlicesMojo.java:68` +- `/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/DeploySlicesMojo.java:87` + +**Details:** +```java +// Line 64-68 +var deployRepoOpt = getDeploymentRepository(); +if (deployRepoOpt.isEmpty()) { + throw new MojoExecutionException("No deployment repository configured"); +} +var deployRepo = deployRepoOpt.unwrap(); // <-- Safe after check +``` + +The unwrap on line 68 is actually safe after the isEmpty() check. However, line 87 has a potential issue: + +```java +// Line 83-87 +var result = SliceManifest.load(manifestPath); +if (result.isFailure()) { + throw new MojoExecutionException("Failed to load manifest: " + manifestPath); +} +var manifest = result.unwrap(); // <-- Safe after isFailure() check +``` + +**Status:** Actually safe due to guard checks. No action needed. + +--- + +## Warnings + +### 1. WARNING: Catch-All Exception Handling with Broad Scope +**Severity:** WARNING +**Files:** +- `/jbct-core/src/main/java/org/pragmatica/jbct/upgrade/JarInstaller.java:80` +- `/jbct-core/src/main/java/org/pragmatica/jbct/upgrade/JarInstaller.java:132` +- `/jbct-core/src/main/java/org/pragmatica/jbct/upgrade/JarInstaller.java:171` +- `/jbct-core/src/main/java/org/pragmatica/jbct/upgrade/JarInstaller.java:205` + +**Details:** +```java +// JarInstaller.java:80 +} catch (Exception e) { + LOG.debug("Could not detect current JAR location: {}", e.getMessage()); +} +``` + +**Impact:** Broad exception catching can mask unexpected errors. While the logging is good, consider catching specific exceptions: +- `MalformedURLException` or `URISyntaxException` for URI parsing +- `IOException` for file access +- `UnsupportedOperationException` for unsupported OS features + +**Recommendation:** More specific exception handling would improve error diagnostics. + +**Status:** Design choice for resilience - acceptable but could be improved. + +--- + +### 2. WARNING: Resource Leak Risk in SliceProjectValidator +**Severity:** WARNING +**File:** `/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectValidator.java:78` + +**Details:** +```java +// Line 78 - This try-with-resources is correct +try (var files = Files.list(sliceDir)) { + var manifestFiles = files.filter(p -> p.toString().endsWith(".manifest")).toList(); + // ... process ... +} +``` + +**Status:** SAFE - Proper try-with-resources usage. + +--- + +### 3. WARNING: Platform-Dependent File Permissions Handling +**Severity:** SUGGESTION +**File:** `/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java:170-180` + +**Details:** +```java +// Silent failure on non-POSIX systems +} catch (UnsupportedOperationException e) { + LOG.debug("POSIX permissions not supported on this platform for {}", path); +} +``` + +**Impact:** Windows compatibility - executable bit silently ignored on non-POSIX filesystems. + +**Status:** Acceptable - properly logged at debug level. + +--- + +## Suggestions + +### 1. SUGGESTION: Temp File Cleanup Strategy +**Severity:** SUGGESTION +**Files:** +- `/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java:386` +- `/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java:445` +- `/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java:464` +- `/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/PackageSlicesMojo.java:596` + +**Details:** +```java +// Line 386 - Creates temp file with deferred cleanup +var tempFile = Files.createTempFile("jbct-class-", ".class") + .deleteOnExit(); // Deferred deletion +Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING); +archiver.addFile(tempFile.toFile(), entryName); +``` + +**Issue:** `deleteOnExit()` defers cleanup until JVM shutdown. For long-running Maven plugins, consider: +1. Explicit deletion after archiver processes the file +2. Try-with-resources for file cleanup scope +3. Temp directory cleanup in finally block + +**Impact:** Potential disk space accumulation in long-running builds with many slices. + +**Recommendation:** +```java +// Better approach +var tempFile = Files.createTempFile("jbct-class-", ".class"); +try { + Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING); + archiver.addFile(tempFile.toFile(), entryName); +} finally { + try { + Files.deleteIfExists(tempFile); + } catch (IOException e) { + getLog().debug("Failed to cleanup temp file: " + e.getMessage()); + } +} +``` + +--- + +### 2. SUGGESTION: HTTP Client Connection Pooling +**Severity:** SUGGESTION +**File:** `/jbct-core/src/main/java/org/pragmatica/jbct/shared/HttpClients.java:15-20` + +**Details:** +```java +// Static shared client with 10-second connect timeout +HttpClient SHARED_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); +``` + +**Observation:** Configuration is appropriate for CLI/Maven plugin usage. + +**Recommendation:** Consider adding socket timeout (read timeout) to prevent hanging on slow networks: +```java +HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NORMAL) + .executor(Executors.newVirtualThreadPerTaskExecutor()) // Java 21+ + .build() +``` + +--- + +### 3. SUGGESTION: Logging Sensitive Information +**Severity:** SUGGESTION +**File:** `/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/DeploySlicesMojo.java:112` + +**Details:** +```java +getLog().info("Deployed: " + project.getGroupId() + ":" + artifactId + ":" + version); +``` + +**Status:** SAFE - Only logs artifact coordinates, not credentials. + +**Observation:** URL validation is properly enforced to prevent unsafe downloads: +- `/jbct-core/src/main/java/org/pragmatica/jbct/shared/UrlValidation.java:25-56` + - HTTPS only + - Whitelist of trusted domains (github.com, api.github.com, raw.githubusercontent.com, objects.githubusercontent.com) + - Proper error messages + +--- + +## Nits & Style + +### 1. NITPICK: Broad Exception Catch with Empty Path Handling +**File:** `/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java:140-158` + +**Details:** +```java +private void copyDirectory(Path source, Path target, List installedFiles) throws IOException { + Files.walkFileTree(source, + new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + var targetDir = target.resolve(source.relativize(dir)); + Files.createDirectories(targetDir); + return FileVisitResult.CONTINUE; + } + // ... + }); +} +``` + +**Observation:** Proper exception propagation. Good pattern. + +--- + +### 2. NITPICK: Concurrent Collections in SliceProcessor +**File:** `/jbct-slice/SliceProcessor.java:47-48` + +**Details:** +```java +private final java.util.Map packageToSlice = + new java.util.concurrent.ConcurrentHashMap<>(); +private final java.util.Set routeServiceEntries = + java.util.Collections.synchronizedSet(new java.util.LinkedHashSet<>()); +``` + +**Observation:** Thread-safe collections for multi-threaded annotation processing. Excellent defensive programming. + +--- + +## Security Assessment + +### Path Traversal Prevention +**Status:** EXCELLENT +**File:** `/jbct-core/src/main/java/org/pragmatica/jbct/shared/PathValidation.java` + +```java +// Proper multi-check validation +1. Reject ".." sequences +2. Reject absolute paths +3. Normalize and validate path stays within base directory +4. Return SecurityError.PathTraversalDetected on violation +``` + +### URL Validation +**Status:** EXCELLENT +**File:** `/jbct-core/src/main/java/org/pragmatica/jbct/shared/UrlValidation.java` + +```java +// Whitelist approach with HTTPS enforcement +- HTTPS only (rejects http://) +- Whitelist of 4 trusted GitHub domains +- Proper error handling +``` + +### No Sensitive Data Leaks +**Status:** GOOD +**Findings:** +- No passwords/tokens logged +- No credentials passed in URLs +- Environment variables read only for standard paths (user.home, java.io.tmpdir) +- Manifest files properly loaded with validation + +--- + +## Error Handling Summary + +| Pattern | Status | Usage | +|---------|--------|-------| +| try-with-resources | GOOD | SourceRoot.java, AiToolsInstaller.java, JarInstaller.java | +| Result pattern | EXCELLENT | Widespread - returns errors instead of exceptions | +| Option pattern | EXCELLENT | Used for optional values | +| Explicit null checks | GOOD | Guard clauses before unwrap() | +| Catch-all exceptions | WARNING | JarInstaller.java (3 locations) - could be more specific | +| Cleanup on error | GOOD | FileInputStream auto-closed with try-with-resources | + +--- + +## Performance Notes + +### Stream Management +- ✓ SourceRoot.java:39 - `Files.walk()` in try-with-resources +- ✓ SliceProjectValidator.java:78 - `Files.list()` in try-with-resources +- ✓ No streams left open + +### File Operations +- ✓ Path normalization used before security checks +- ✓ Batch JAR operations with single archiver instance +- ✓ Stream processing for large collections + +--- + +## Compliance Checklist + +| Check | Status | Notes | +|-------|--------|-------| +| Aspects pattern for cross-cutting concerns | N/A | Not applicable - tool is CLI/plugin, not domain code | +| Proper error handling at boundaries | ✓ PASS | Result/Option used throughout | +| Resource cleanup (try-with-resources) | ✓ PASS | Used correctly in file operations | +| No security vulnerabilities | ✓ PASS | Path traversal and URL validation excellent | +| No unchecked .unwrap() calls | ✓ PASS | All unwrap() protected by guard checks | +| Logging not exposing sensitive data | ✓ PASS | No credentials/tokens logged | +| Thread-safe collections where needed | ✓ PASS | ConcurrentHashMap in SliceProcessor | +| Proper exception propagation | ✓ PASS | IOException properly propagated | + +--- + +## Recommendations (Priority Order) + +### HIGH +1. **JarInstaller exception handling** - More specific exception types instead of broad `catch(Exception e)` at lines 80, 132, 171, 205 + +### MEDIUM +2. **PackageSlicesMojo temp file cleanup** - Explicit deletion instead of `deleteOnExit()` for better resource management in long-running builds + +### LOW +3. **HttpClient socket timeout** - Add read timeout configuration for network resilience +4. **SliceProjectValidator logging** - Consider debug-level logging for file scanning operations + +--- + +## Conclusion + +**Overall Grade: A (Excellent)** + +The jbct-cli codebase demonstrates strong practices for cross-cutting concerns: + +✓ Security: Path traversal and URL validation are exemplary +✓ Resource Management: Try-with-resources used appropriately +✓ Error Handling: Result/Option pattern eliminates null pointer issues +✓ Logging: Appropriate levels, no sensitive data leaks +✓ Thread Safety: Concurrent collections used in multi-threaded contexts + +Minor improvements in temp file cleanup and exception specificity would move this to A+ territory. diff --git a/jbct-cli/src/main/java/org/pragmatica/jbct/cli/FormatCommand.java b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/FormatCommand.java index 9c0c33b..e95b181 100644 --- a/jbct-cli/src/main/java/org/pragmatica/jbct/cli/FormatCommand.java +++ b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/FormatCommand.java @@ -11,7 +11,6 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.Callable; -import java.util.concurrent.atomic.AtomicInteger; import picocli.CommandLine.Command; import picocli.CommandLine.Parameters; @@ -65,17 +64,15 @@ public Integer call() { if (verbose) { System.out.println("Found " + filesToProcess.size() + " Java file(s) to process."); } - var formatted = new AtomicInteger(0); - var unchanged = new AtomicInteger(0); - var errors = new AtomicInteger(0); + var counters = new int[3]; // 0=formatted, 1=unchanged, 2=errors var needsFormatting = new ArrayList(); for (var file : filesToProcess) { - processFile(file, formatted, unchanged, errors, needsFormatting); + processFile(file, counters, needsFormatting); } // Print summary - printSummary(formatted.get(), unchanged.get(), errors.get(), needsFormatting); + printSummary(counters[0], counters[1], counters[2], needsFormatting); // Return appropriate exit code - if (errors.get() > 0) { + if (counters[2] > 0) { return 2; } if (checkOnly && !needsFormatting.isEmpty()) { @@ -88,34 +85,27 @@ private List collectJavaFiles() { return FileCollector.collectJavaFiles(paths, System.err::println); } - private void processFile(Path file, - AtomicInteger formatted, - AtomicInteger unchanged, - AtomicInteger errors, - List needsFormatting) { + private void processFile(Path file, int[] counters, List needsFormatting) { SourceFile.sourceFile(file) - .flatMap(source -> checkAndFormat(source, file, formatted, unchanged, needsFormatting)) + .flatMap(source -> checkAndFormat(source, file, counters, needsFormatting)) .onFailure(cause -> { - errors.incrementAndGet(); + counters[2]++; System.err.println(" error: " + file + " - " + cause.message()); }); } private org.pragmatica.lang.Result checkAndFormat(SourceFile source, Path file, - AtomicInteger formatted, - AtomicInteger unchanged, + int[] counters, List needsFormatting) { return formatter.isFormatted(source) .flatMap(isFormatted -> isFormatted - ? handleUnchanged(source, file, unchanged) - : handleNeedsFormatting(source, file, formatted, needsFormatting)); + ? handleUnchanged(source, file, counters) + : handleNeedsFormatting(source, file, counters, needsFormatting)); } - private org.pragmatica.lang.Result handleUnchanged(SourceFile source, - Path file, - AtomicInteger unchanged) { - unchanged.incrementAndGet(); + private org.pragmatica.lang.Result handleUnchanged(SourceFile source, Path file, int[] counters) { + counters[1]++; if (verbose) { System.out.println(" unchanged: " + file); } @@ -124,33 +114,29 @@ private org.pragmatica.lang.Result handleUnchanged(SourceFile source private org.pragmatica.lang.Result handleNeedsFormatting(SourceFile source, Path file, - AtomicInteger formatted, + int[] counters, List needsFormatting) { needsFormatting.add(file); if (checkOnly) { System.out.println(" needs formatting: " + file); return org.pragmatica.lang.Result.success(source); } - return formatAndWrite(source, file, formatted); + return formatAndWrite(source, file, counters); } - private org.pragmatica.lang.Result formatAndWrite(SourceFile source, - Path file, - AtomicInteger formatted) { + private org.pragmatica.lang.Result formatAndWrite(SourceFile source, Path file, int[] counters) { return formatter.format(source) - .flatMap(formattedSource -> writeFormatted(formattedSource, file, formatted)); + .flatMap(formattedSource -> writeFormatted(formattedSource, file, counters)); } - private org.pragmatica.lang.Result writeFormatted(SourceFile formattedSource, - Path file, - AtomicInteger formatted) { + private org.pragmatica.lang.Result writeFormatted(SourceFile formattedSource, Path file, int[] counters) { if (dryRun) { System.out.println(" would format: " + file); return org.pragmatica.lang.Result.success(formattedSource); } return formattedSource.write() .onSuccess(_ -> { - formatted.incrementAndGet(); + counters[0]++; if (verbose) { System.out.println(" formatted: " + file); } diff --git a/jbct-cli/src/main/java/org/pragmatica/jbct/cli/InitCommand.java b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/InitCommand.java index ecf2311..6a81a9e 100644 --- a/jbct-cli/src/main/java/org/pragmatica/jbct/cli/InitCommand.java +++ b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/InitCommand.java @@ -82,16 +82,12 @@ public Integer call() { return 1; } // Determine project directory - if (projectDir == null) { - projectDir = Path.of(System.getProperty("user.dir")); - } else { - projectDir = projectDir.toAbsolutePath(); - } + projectDir = org.pragmatica.lang.Option.option(projectDir) + .map(Path::toAbsolutePath) + .or(() -> Path.of(System.getProperty("user.dir"))); // Determine artifact ID from directory name if not specified - if (artifactId == null) { - artifactId = projectDir.getFileName() - .toString(); - } + artifactId = org.pragmatica.lang.Option.option(artifactId) + .or(() -> projectDir.getFileName().toString()); var projectCreated = false; var aiToolsInstalled = false; // Create project structure unless --ai-only @@ -184,21 +180,18 @@ private boolean hasVersionOverrides() { } private String effectivePragmaticaVersion() { - return pragmaticaVersion != null - ? pragmaticaVersion - : GitHubVersionResolver.defaultPragmaticaVersion(); + return org.pragmatica.lang.Option.option(pragmaticaVersion) + .or(GitHubVersionResolver::defaultPragmaticaVersion); } private String effectiveAetherVersion() { - return aetherVersion != null - ? aetherVersion - : GitHubVersionResolver.defaultAetherVersion(); + return org.pragmatica.lang.Option.option(aetherVersion) + .or(GitHubVersionResolver::defaultAetherVersion); } private String effectiveJbctVersion() { - return jbctVersion != null - ? jbctVersion - : GitHubVersionResolver.defaultJbctVersion(); + return org.pragmatica.lang.Option.option(jbctVersion) + .or(GitHubVersionResolver::defaultJbctVersion); } private boolean initSliceProject() { diff --git a/jbct-cli/src/main/java/org/pragmatica/jbct/cli/LintCommand.java b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/LintCommand.java index 922d6ab..a49f07e 100644 --- a/jbct-cli/src/main/java/org/pragmatica/jbct/cli/LintCommand.java +++ b/jbct-cli/src/main/java/org/pragmatica/jbct/cli/LintCommand.java @@ -14,7 +14,6 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.Callable; -import java.util.concurrent.atomic.AtomicInteger; import picocli.CommandLine.Command; import picocli.CommandLine.Parameters; @@ -75,22 +74,19 @@ public Integer call() { System.out.println("Found " + filesToProcess.size() + " Java file(s) to lint."); } var allDiagnostics = new ArrayList(); - var errors = new AtomicInteger(0); - var warnings = new AtomicInteger(0); - var infos = new AtomicInteger(0); - var parseErrors = new AtomicInteger(0); + var counters = new int[4]; // 0=errors, 1=warnings, 2=infos, 3=parseErrors for (var file : filesToProcess) { - processFile(file, linter, allDiagnostics, errors, warnings, infos, parseErrors); + processFile(file, linter, allDiagnostics, counters); } // Output results printResults(allDiagnostics); // Print summary - printSummary(filesToProcess.size(), errors.get(), warnings.get(), infos.get(), parseErrors.get()); + printSummary(filesToProcess.size(), counters[0], counters[1], counters[2], counters[3]); // Return appropriate exit code - if (parseErrors.get() > 0 || errors.get() > 0) { + if (counters[3] > 0 || counters[0] > 0) { return 2; } - if (failOnWarning && warnings.get() > 0) { + if (failOnWarning && counters[1] > 0) { return 1; } return 0; @@ -110,22 +106,16 @@ private List collectJavaFiles() { return FileCollector.collectJavaFiles(paths, System.err::println); } - private void processFile(Path file, - JbctLinter linter, - List allDiagnostics, - AtomicInteger errors, - AtomicInteger warnings, - AtomicInteger infos, - AtomicInteger parseErrors) { + private void processFile(Path file, JbctLinter linter, List allDiagnostics, int[] counters) { SourceFile.sourceFile(file) .flatMap(linter::lint) .onSuccess(diagnostics -> { allDiagnostics.addAll(diagnostics); for (var d : diagnostics) { switch (d.severity()) { - case ERROR -> errors.incrementAndGet(); - case WARNING -> warnings.incrementAndGet(); - case INFO -> infos.incrementAndGet(); + case ERROR -> counters[0]++; + case WARNING -> counters[1]++; + case INFO -> counters[2]++; } } if (verbose && diagnostics.isEmpty()) { @@ -133,7 +123,7 @@ private void processFile(Path file, } }) .onFailure(cause -> { - parseErrors.incrementAndGet(); + counters[3]++; System.err.println(" ✗ " + file + ": " + cause.message()); }); } diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/format/FormattingError.java b/jbct-core/src/main/java/org/pragmatica/jbct/format/FormattingError.java index f55320f..cc591f4 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/format/FormattingError.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/format/FormattingError.java @@ -7,7 +7,7 @@ * Sealed interface for formatting errors. */ public sealed interface FormattingError extends Cause { - record ParseError(String file, int line, int column, String details) implements FormattingError { + record ParseFailed(String file, int line, int column, String details) implements FormattingError { @Override public String message() { return "Parse error at %s:%d:%d - %s". formatted(file, line, column, details); @@ -19,7 +19,7 @@ public Option source() { } } - record IoError(String file, Throwable exception) implements FormattingError { + record IoFailed(String file, Throwable exception) implements FormattingError { @Override public String message() { return "I/O error for %s: %s". formatted(file, exception.getMessage()); @@ -44,12 +44,12 @@ public Option source() { } // Factory methods - static FormattingError parseError(String file, int line, int column, String details) { - return new ParseError(file, line, column, details); + static FormattingError parseFailed(String file, int line, int column, String details) { + return new ParseFailed(file, line, column, details); } - static FormattingError ioError(String file, Throwable exception) { - return new IoError(file, exception); + static FormattingError ioFailed(String file, Throwable exception) { + return new IoFailed(file, exception); } static FormattingError formatterError(String details) { diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/format/cst/CstFormatter.java b/jbct-core/src/main/java/org/pragmatica/jbct/format/cst/CstFormatter.java index aff6363..3595edf 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/format/cst/CstFormatter.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/format/cst/CstFormatter.java @@ -51,12 +51,12 @@ private Result parse(SourceFile source) { var result = parser.parseWithDiagnostics(source.content()); if (result.isSuccess()) { return result.node() - .toResult(FormattingError.parseError(source.fileName(), 1, 1, "Parse error")); + .toResult(FormattingError.parseFailed(source.fileName(), 1, 1, "Parse error")); } return result.diagnostics() .stream() .findFirst() - .map(d -> FormattingError.parseError(source.fileName(), + .map(d -> FormattingError.parseFailed(source.fileName(), d.span() .start() .line(), @@ -64,7 +64,7 @@ private Result parse(SourceFile source) { .start() .column(), d.message())) - .orElse(FormattingError.parseError(source.fileName(), 1, 1, "Parse error")) + .orElse(FormattingError.parseFailed(source.fileName(), 1, 1, "Parse error")) .result(); } diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java index 96fa155..f777b2b 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/init/SliceProjectInitializer.java @@ -143,14 +143,20 @@ private Result> createAllFiles() { createTestResources(), createDeployScripts(), createSliceConfigFiles()) - .flatMap(fileLists -> createDependencyManifest() - .map(manifest -> { - var allFiles = fileLists.stream() - .flatMap(List::stream) - .collect(java.util.stream.Collectors.toCollection(ArrayList::new)); - allFiles.add(manifest); - return allFiles; - })); + .flatMap(this::combineWithDependencyManifest); + } + + private Result> combineWithDependencyManifest(List> fileLists) { + return createDependencyManifest() + .map(manifest -> combineFileLists(fileLists, manifest)); + } + + private List combineFileLists(List> fileLists, Path manifest) { + var allFiles = fileLists.stream() + .flatMap(List::stream) + .collect(java.util.stream.Collectors.toCollection(ArrayList::new)); + allFiles.add(manifest); + return allFiles; } private Result> createSliceConfigFiles() { diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/parser/CstNodes.java b/jbct-core/src/main/java/org/pragmatica/jbct/parser/CstNodes.java index 390d72a..778f697 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/parser/CstNodes.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/parser/CstNodes.java @@ -99,14 +99,16 @@ public static Option findFirst(CstNode root, Predicate predica */ public static Option findAncestor(CstNode root, CstNode target, Class ruleClass) { return findAncestorPath(root, target) - .flatMap(path -> { - for (int i = path.size() - 2; i >= 0; i--) { - if (isRule(path.get(i), ruleClass)) { - return Option.some(path.get(i)); - } - } - return Option.none(); - }); + .flatMap(path -> findAncestorInPath(path, ruleClass)); + } + + private static Option findAncestorInPath(List path, Class ruleClass) { + for (int i = path.size() - 2; i >= 0; i--) { + if (isRule(path.get(i), ruleClass)) { + return Option.some(path.get(i)); + } + } + return Option.none(); } /** diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/shared/FileCollector.java b/jbct-core/src/main/java/org/pragmatica/jbct/shared/FileCollector.java index bc06a32..7e6563f 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/shared/FileCollector.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/shared/FileCollector.java @@ -35,7 +35,7 @@ static List collectJavaFiles(List paths, Consumer errorHandl files.add(path); } } - return files; + return List.copyOf(files); } /** @@ -58,7 +58,7 @@ static List collectFromDirectories(Option sourceDirectory, testSourceDirectory.filter(Files::exists) .onPresent(dir -> collectFromDirectory(dir, files, errorHandler)); } - return files; + return List.copyOf(files); } private static void collectFromDirectory(Path directory, List files, Consumer errorHandler) { diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceManifest.java b/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceManifest.java index 002415b..7a4ec9d 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceManifest.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/slice/SliceManifest.java @@ -80,27 +80,29 @@ private static Result fromProperties(Properties props) { return org.pragmatica.lang.Option.option(props.getProperty("slice.name")) .filter(s -> !s.isEmpty()) .toResult(Causes.cause("Missing required property: slice.name")) - .map(sliceName -> { - var artifactSuffix = getPropertyOrEmpty(props, "slice.artifactSuffix"); - var slicePackage = getPropertyOrEmpty(props, "slice.package"); - var implClasses = parseList(getPropertyOrEmpty(props, "impl.classes")); - var requestClasses = parseList(getPropertyOrEmpty(props, "request.classes")); - var responseClasses = parseList(getPropertyOrEmpty(props, "response.classes")); - var baseArtifact = getPropertyOrEmpty(props, "base.artifact"); - var implArtifactId = getPropertyOrEmpty(props, "slice.artifactId"); - var dependencies = parseDependencies(props); - var configFile = getPropertyOrEmpty(props, "config.file"); - return new SliceManifest(sliceName, - artifactSuffix, - slicePackage, - implClasses, - requestClasses, - responseClasses, - baseArtifact, - implArtifactId, - dependencies, - configFile); - }); + .map(sliceName -> buildManifest(sliceName, props)); + } + + private static SliceManifest buildManifest(String sliceName, Properties props) { + var artifactSuffix = getPropertyOrEmpty(props, "slice.artifactSuffix"); + var slicePackage = getPropertyOrEmpty(props, "slice.package"); + var implClasses = parseList(getPropertyOrEmpty(props, "impl.classes")); + var requestClasses = parseList(getPropertyOrEmpty(props, "request.classes")); + var responseClasses = parseList(getPropertyOrEmpty(props, "response.classes")); + var baseArtifact = getPropertyOrEmpty(props, "base.artifact"); + var implArtifactId = getPropertyOrEmpty(props, "slice.artifactId"); + var dependencies = parseDependencies(props); + var configFile = getPropertyOrEmpty(props, "config.file"); + return new SliceManifest(sliceName, + artifactSuffix, + slicePackage, + implClasses, + requestClasses, + responseClasses, + baseArtifact, + implArtifactId, + dependencies, + configFile); } private static String getPropertyOrEmpty(Properties props, String key) { diff --git a/jbct-core/src/main/java/org/pragmatica/jbct/update/AiToolsUpdater.java b/jbct-core/src/main/java/org/pragmatica/jbct/update/AiToolsUpdater.java index 01bb1c0..4ae88f3 100644 --- a/jbct-core/src/main/java/org/pragmatica/jbct/update/AiToolsUpdater.java +++ b/jbct-core/src/main/java/org/pragmatica/jbct/update/AiToolsUpdater.java @@ -78,15 +78,17 @@ public Result> update() { */ public Result> update(boolean force) { return GitHubContentFetcher.getLatestCommitSha(http, REPO, BRANCH) - .flatMap(latestSha -> { - var currentSha = getCurrentVersion(); - var isUpToDate = !force && currentSha.filter(sha -> sha.equals(latestSha)) - .isPresent(); - if (isUpToDate) { - return Result.success(List.of()); - } - return downloadFiles(latestSha); - }); + .flatMap(latestSha -> performUpdate(latestSha, force)); + } + + private Result> performUpdate(String latestSha, boolean force) { + var currentSha = getCurrentVersion(); + var isUpToDate = !force && currentSha.filter(sha -> sha.equals(latestSha)) + .isPresent(); + if (isUpToDate) { + return Result.success(List.of()); + } + return downloadFiles(latestSha); } private Result> downloadFiles(String commitSha) { diff --git a/jbct-core/src/test/java/org/pragmatica/jbct/format/JbctFormatterTest.java b/jbct-core/src/test/java/org/pragmatica/jbct/format/JbctFormatterTest.java index 255467c..b2d18eb 100644 --- a/jbct-core/src/test/java/org/pragmatica/jbct/format/JbctFormatterTest.java +++ b/jbct-core/src/test/java/org/pragmatica/jbct/format/JbctFormatterTest.java @@ -25,17 +25,16 @@ public String hello() { } } """); - var result = formatter.format(source); - assertThat(result.isSuccess()) - .as("Format should succeed") - .isTrue(); - var formatted = result.unwrap(); - assertThat(formatted.content()) - .contains("package com.example;"); - assertThat(formatted.content()) - .contains("public class Test"); - assertThat(formatted.content()) - .contains("return \"world\""); + formatter.format(source) + .onFailure(cause -> Assertions.fail(cause.message())) + .onSuccess(formatted -> { + assertThat(formatted.content()) + .contains("package com.example;"); + assertThat(formatted.content()) + .contains("public class Test"); + assertThat(formatted.content()) + .contains("return \"world\""); + }); } @Test @@ -52,19 +51,18 @@ public Result process() { } } """); - var result = formatter.format(source); - assertThat(result.isSuccess()) - .as("Format should succeed") - .isTrue(); - var formatted = result.unwrap(); - // Verify chain structure is preserved - assertThat(formatted.content()) - .contains("Result.success(\"hello\")"); - // Verify method chain operations are preserved - assertThat(formatted.content()) - .contains(".map(String::toUpperCase)"); - assertThat(formatted.content()) - .contains(".flatMap(s -> Result.success(s + \"!\"))"); + formatter.format(source) + .onFailure(cause -> Assertions.fail(cause.message())) + .onSuccess(formatted -> { + // Verify chain structure is preserved + assertThat(formatted.content()) + .contains("Result.success(\"hello\")"); + // Verify method chain operations are preserved + assertThat(formatted.content()) + .contains(".map(String::toUpperCase)"); + assertThat(formatted.content()) + .contains(".flatMap(s -> Result.success(s + \"!\"))"); + }); } @Test @@ -76,17 +74,14 @@ void isFormatted_returnsTrue_forAlreadyFormattedCode() { public class Formatted { } """); - var result = formatter.format(source) - .flatMap(formatter::isFormatted); - assertThat(result.isSuccess()) - .as("Format check should succeed") - .isTrue(); - assertThat(result.unwrap()) - .isTrue(); + formatter.format(source) + .flatMap(formatter::isFormatted) + .onFailure(cause -> Assertions.fail(cause.message())) + .onSuccess(isFormatted -> assertThat(isFormatted).isTrue()); } @Test - void format_returnsParseError_forInvalidSyntax() { + void format_returnsParseFailed_forInvalidSyntax() { var source = new SourceFile(Path.of("Invalid.java"), """ package com.example; @@ -99,6 +94,6 @@ public class Invalid { .as("Format should fail for invalid syntax") .isTrue(); result.onFailure(cause -> assertThat(cause) - .isInstanceOf(FormattingError.ParseError.class)); + .isInstanceOf(FormattingError.ParseFailed.class)); } } diff --git a/slice-processor-tests/src/main/java/com/example/testslice/TestSlice.java b/slice-processor-tests/src/main/java/com/example/testslice/TestSlice.java index 26a6144..1836ed2 100644 --- a/slice-processor-tests/src/main/java/com/example/testslice/TestSlice.java +++ b/slice-processor-tests/src/main/java/com/example/testslice/TestSlice.java @@ -34,6 +34,41 @@ public interface TestSlice { Promise health(HealthRequest request); static TestSlice testSlice() { - return new TestSliceImpl(); + return new TestSlice() { + @Override + public Promise create(CreateRequest request) { + return Promise.success(new CreateResponse(1L, request.name())); + } + + @Override + public Promise getById(GetByIdRequest request) { + return Promise.success(new GetResponse(request.id(), "Test", "test@example.com")); + } + + @Override + public Promise getItem(GetItemRequest request) { + return Promise.success(new ItemResponse(request.itemId(), "Item", 10)); + } + + @Override + public Promise> search(SearchRequest request) { + return Promise.success(List.of(new SearchResult(1L, "Result", 0.95))); + } + + @Override + public Promise update(UpdateRequest request) { + return Promise.success(new UpdateResponse(request.id(), request.name(), true)); + } + + @Override + public Promise> getOrders(GetOrdersRequest request) { + return Promise.success(List.of(new OrderResponse(1L, "completed", 99.99))); + } + + @Override + public Promise health(HealthRequest request) { + return Promise.success(new HealthResponse("healthy", System.currentTimeMillis())); + } + }; } } diff --git a/slice-processor-tests/src/main/java/com/example/testslice/TestSliceImpl.java b/slice-processor-tests/src/main/java/com/example/testslice/TestSliceImpl.java deleted file mode 100644 index 18b7cbb..0000000 --- a/slice-processor-tests/src/main/java/com/example/testslice/TestSliceImpl.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.example.testslice; - -import org.pragmatica.lang.Promise; - -import java.util.List; - -class TestSliceImpl implements TestSlice { - - @Override - public Promise create(CreateRequest request) { - return Promise.success(new CreateResponse(1L, request.name())); - } - - @Override - public Promise getById(GetByIdRequest request) { - return Promise.success(new GetResponse(request.id(), "Test", "test@example.com")); - } - - @Override - public Promise getItem(GetItemRequest request) { - return Promise.success(new ItemResponse(request.itemId(), "Item", 10)); - } - - @Override - public Promise> search(SearchRequest request) { - return Promise.success(List.of(new SearchResult(1L, "Result", 0.95))); - } - - @Override - public Promise update(UpdateRequest request) { - return Promise.success(new UpdateResponse(request.id(), request.name(), true)); - } - - @Override - public Promise> getOrders(GetOrdersRequest request) { - return Promise.success(List.of(new OrderResponse(1L, "completed", 99.99))); - } - - @Override - public Promise health(HealthRequest request) { - return Promise.success(new HealthResponse("healthy", System.currentTimeMillis())); - } -} diff --git a/slice-processor/src/test/java/org/pragmatica/jbct/slice/SliceProcessorTest.java b/slice-processor/src/test/java/org/pragmatica/jbct/slice/SliceProcessorTest.java index 67bb497..0d49a69 100644 --- a/slice-processor/src/test/java/org/pragmatica/jbct/slice/SliceProcessorTest.java +++ b/slice-processor/src/test/java/org/pragmatica/jbct/slice/SliceProcessorTest.java @@ -531,7 +531,7 @@ static OrderService orderService(OrderValidator validator, PaymentService paymen assertCompilation(compilation).succeeded(); var factoryContent = compilation.generatedSourceFile("test.OrderServiceFactory") - .orElseThrow() + .get() .getCharContent(false) .toString(); @@ -622,7 +622,7 @@ static OrderProcessor orderProcessor(OrderValidator validator, assertCompilation(compilation).succeeded(); var factoryContent = compilation.generatedSourceFile("test.OrderProcessorFactory") - .orElseThrow() + .get() .getCharContent(false) .toString(); @@ -666,7 +666,7 @@ static UserService userService() { assertCompilation(compilation).succeeded(); var factoryContent = compilation.generatedSourceFile("test.UserServiceFactory") - .orElseThrow() + .get() .getCharContent(false) .toString(); @@ -729,7 +729,7 @@ static UserService userService() { assertCompilation(compilation).succeeded(); var factoryContent = compilation.generatedSourceFile("test.UserServiceFactory") - .orElseThrow() + .get() .getCharContent(false) .toString(); @@ -792,7 +792,7 @@ static OrderService orderService() { assertThat(manifestFile.isPresent()).isTrue(); - var manifestContent = manifestFile.orElseThrow() + var manifestContent = manifestFile.get() .getCharContent(false) .toString(); @@ -854,7 +854,7 @@ static OrderService orderService(InventoryService inventory) { assertThat(manifestFile.isPresent()).isTrue(); - var manifestContent = manifestFile.orElseThrow() + var manifestContent = manifestFile.get() .getCharContent(false) .toString(); @@ -925,7 +925,7 @@ static UserService userService() { assertCompilation(compilation).succeeded(); var factoryContent = compilation.generatedSourceFile("test.UserServiceFactory") - .orElseThrow() + .get() .getCharContent(false) .toString(); @@ -995,7 +995,7 @@ static UserService userService() { assertCompilation(compilation).succeeded(); var factoryContent = compilation.generatedSourceFile("test.UserServiceFactory") - .orElseThrow() + .get() .getCharContent(false) .toString(); @@ -1077,7 +1077,7 @@ static UserService userService() { assertCompilation(compilation).succeeded(); var factoryContent = compilation.generatedSourceFile("test.UserServiceFactory") - .orElseThrow() + .get() .getCharContent(false) .toString(); @@ -1133,7 +1133,7 @@ static UserService userService() { assertCompilation(compilation).succeeded(); var factoryContent = compilation.generatedSourceFile("test.UserServiceFactory") - .orElseThrow() + .get() .getCharContent(false) .toString(); @@ -1378,7 +1378,7 @@ static OrderService orderService() { assertThat(manifestFile.isPresent()).isTrue(); - var manifestContent = manifestFile.orElseThrow() + var manifestContent = manifestFile.get() .getCharContent(false) .toString(); From 72a71550e8c6768fb0ea1dc62cbb6207662b4e39 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Sun, 1 Feb 2026 14:03:05 +0100 Subject: [PATCH 45/46] docs: document JBCT pattern exceptions for resource loading Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 9c31345..e44397d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,32 @@ To invoke: Use the `/jbct` skill or spawn the `jbct-coder` agent via the Task to Until release 1.0, backward compatibility is not guaranteed. Breaking changes are acceptable for JBCT compliance improvements. +## JBCT Pattern Exceptions + +The following deviations from strict JBCT patterns are accepted in this codebase: + +### Resource Loading Null Checks + +Classpath resource loading uses direct null checks instead of Option pattern due to try-with-resources complexity: + +```java +try (var in = Class.class.getResourceAsStream(path)) { + if (in == null) { + return Causes.cause("Resource not found").result(); + } + // use stream +} +``` + +**Affected files:** +- `JarInstaller.java:226` - Copying wrapper scripts +- `ProjectInitializer.java:189` - Loading templates +- `SliceProjectInitializer.java:226` - Loading templates +- `GitHubVersionResolver.java:55` - Loading version properties +- `Version.java:18` - Loading version properties + +**Rationale:** Wrapping InputStream in Option requires splitting into two methods to handle try-with-resources correctly. The added complexity outweighs the benefit for adapter-layer code. + ## Project Overview CLI tool and Maven plugin for JBCT (Java Backend Coding Technology) code formatting and linting. From be46bca3aa82c4172fbe5a56fa5a6f5c47f47e44 Mon Sep 17 00:00:00 2001 From: Sergiy Yevtushenko Date: Sun, 1 Feb 2026 14:15:01 +0100 Subject: [PATCH 46/46] fix: address PR review comments for slice processing - FactoryClassGenerator: fix infra flatMap nesting for variable scoping - GenerateBlueprintMojo: properly handle UNRESOLVED dependency edges - CollectSliceDepsMojo: tighten base.artifact validation, improve docs - HTTP-ROUTE-GENERATION.md: add text language to code blocks - CHANGELOG.md: remove empty section headings --- CHANGELOG.md | 19 ++-------- .../jbct/maven/CollectSliceDepsMojo.java | 16 +++++---- .../jbct/maven/GenerateBlueprintMojo.java | 19 +++++++++- slice-processor/docs/HTTP-ROUTE-GENERATION.md | 10 +++--- .../generator/FactoryClassGenerator.java | 35 +++++++++++++------ 5 files changed, 60 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbd60af..7b40c56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,9 @@ - Slice init: `Promise.success()` instead of `Promise.successful()` - Slice init: implemented `message()` method in `ValidationError.EmptyValue` - Slice init: test template now uses monadic composition instead of `.unwrap()` +- FactoryClassGenerator: infra flatMaps now use proper nesting for variable scoping +- GenerateBlueprintMojo: UNRESOLVED dependency edges now properly resolved in graph traversal +- CollectSliceDepsMojo: improved base.artifact validation (rejects spaces, slashes) ## [0.5.0] - 2026-01-20 @@ -155,8 +158,6 @@ ## [0.4.7] - 2026-01-10 -### Added - ### Changed - Build: bump Pragmatica Lite to 0.9.10 - Docs: update README with missing CLI options (--config, --version, --artifact-id, etc.) @@ -173,8 +174,6 @@ ## [0.4.6] - 2026-01-05 -### Added - ### Changed - Build: bump Pragmatica Lite to 0.9.7 - Slice processor: refactored models to use `Result` instead of exceptions (JBCT compliance) @@ -193,23 +192,15 @@ ## [0.4.5] - 2026-01-02 -### Added - ### Changed - Build: bump Pragmatica Lite to 0.9.4 -### Fixed - ## [0.4.4] - 2026-01-01 -### Added - ### Changed - AI tools: update to JBCT v2.0.10 with Pragmatica Lite Core 0.9.3 - Build: bump Pragmatica Lite to 0.9.3 -### Fixed - ## [0.4.3] - 2025-12-31 ### Added @@ -233,10 +224,6 @@ ## [0.4.2] - 2025-12-30 -### Added - -### Changed - ### Fixed - Parser: `record` as contextual keyword - works as method name, type name, field type, variable type diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java index 6a6670e..03c449d 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/CollectSliceDepsMojo.java @@ -25,9 +25,11 @@ * *

For each dependency JAR containing META-INF/slice/*.manifest files, extracts: *

    - *
  • slice.interface - the slice interface fully qualified name
  • - *
  • slice.artifactId - the slice artifact ID
  • - *
  • base.artifact - the groupId:baseArtifactId for dependency resolution
  • + *
  • {@code slice.interface} - the slice interface fully qualified name
  • + *
  • {@code slice.artifactId} - the slice artifact ID
  • + *
  • {@code base.artifact} - the groupId:baseArtifactId for dependency resolution. + * Format: {@code groupId:artifactId} where both components are non-blank Maven coordinates. + * Example: {@code org.example:inventory-service}
  • *
* *

Writes mappings to slice-deps.properties in format: @@ -100,13 +102,15 @@ private void extractSliceManifest(File jarFile, String version, Properties mappi var baseArtifact = props.getProperty("base.artifact"); String groupId; if (baseArtifact != null && baseArtifact.contains(":")) { - var parts = baseArtifact.split(":"); - if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) { + var parts = baseArtifact.split(":", -1); + if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank() + || parts[0].contains(" ") || parts[1].contains(" ") + || parts[0].contains("/") || parts[1].contains("/")) { getLog().warn("Invalid base.artifact format in " + jarFile.getName() + " (" + entryName + "): expected 'groupId:artifactId', got '" + baseArtifact + "'"); continue; } - groupId = parts[0]; + groupId = parts[0].trim(); } else { getLog().warn("Missing or invalid base.artifact in " + jarFile.getName() + " (" + entryName + ")"); continue; diff --git a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/GenerateBlueprintMojo.java b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/GenerateBlueprintMojo.java index 2db6f59..1b7c822 100644 --- a/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/GenerateBlueprintMojo.java +++ b/jbct-maven-plugin/src/main/java/org/pragmatica/jbct/maven/GenerateBlueprintMojo.java @@ -142,6 +142,10 @@ private void resolveExternalDependencies(SliceManifest manifest, // Dependency already in graph with resolved version, skip adding duplicate continue; } + // UNRESOLVED dependency not in graph - skip and warn + getLog().warn("Skipping UNRESOLVED dependency: " + dep.artifact() + + " - not found in local graph"); + continue; } loadManifestFromDependency(dep.artifact(), dep.version()).onPresent(depManifest -> { @@ -260,7 +264,20 @@ private void dfs(String artifact, Map graph) if (dep.artifact() != null && !dep.artifact() .isEmpty()) { // Dependency with artifact coordinates - depArtifact = dep.artifact() + ":" + dep.version(); + if ("UNRESOLVED".equals(dep.version())) { + // Find matching resolved key in graph + var artifactPrefix = dep.artifact() + ":"; + depArtifact = graph.keySet() + .stream() + .filter(key -> key.startsWith(artifactPrefix)) + .findFirst() + .orElse(null); + if (depArtifact == null) { + continue; // Skip unresolved deps not in graph + } + } else { + depArtifact = dep.artifact() + ":" + dep.version(); + } } else { // Local dependency: look up in interfaceToArtifact map depArtifact = interfaceToArtifact.get(dep.interfaceQualifiedName()); diff --git a/slice-processor/docs/HTTP-ROUTE-GENERATION.md b/slice-processor/docs/HTTP-ROUTE-GENERATION.md index d455c7f..ac2fad8 100644 --- a/slice-processor/docs/HTTP-ROUTE-GENERATION.md +++ b/slice-processor/docs/HTTP-ROUTE-GENERATION.md @@ -23,7 +23,7 @@ This document describes the automatic generation of HTTP route handling code fro **Existing projects**: If adding routing to an existing slice project, add the dependency above. **Compile-time validation**: If `routes.toml` exists but the dependency is missing, compilation fails with: -```text +``` ERROR: HTTP routing configured but dependency missing. Add to pom.xml: @@ -64,7 +64,7 @@ When `routes.toml` exists in the slice package resources, the processor generate - Single line per route **Syntax**: -``` +```text "METHOD /path/{param:Type}?query1&query2:Type" ``` @@ -312,7 +312,7 @@ public final class UserServiceRoutes implements RouteSource, SliceRouterFactory< ``` **Generated**: `META-INF/services/org.pragmatica.aether.http.adapter.SliceRouterFactory` -``` +```text com.example.users.UserServiceRoutes ``` @@ -453,7 +453,7 @@ Route.get("/api/v1/health") If a type matches multiple patterns with different status codes: -``` +```text ERROR: Ambiguous error mapping for 'UserNotFoundInvalid': - Matches HTTP_404 pattern "*NotFound*" - Matches HTTP_400 pattern "*Invalid*" @@ -507,7 +507,7 @@ var router = factory.create(userServiceImpl); ## File Structure -``` +```text slice-processor/ ├── src/main/java/org/pragmatica/jbct/slice/ │ ├── SliceProcessor.java # Annotation processor entry point diff --git a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java index cc5490c..99d4af8 100644 --- a/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java +++ b/slice-processor/src/main/java/org/pragmatica/jbct/slice/generator/FactoryClassGenerator.java @@ -258,7 +258,8 @@ private void generateAspectCreateChain(PrintWriter out, out.println(" .async()"); out.println(" .flatMap(cfg -> factory.create(Cache.class, cfg).async()))"); } - // Handle infra dependencies first + // Handle infra dependencies first - keep flatMaps open for variable scoping + var openInfraFlatMaps = 0; if (!infraDeps.isEmpty()) { var infraPrevVar = cacheVarNames.isEmpty() ? "factory" @@ -266,9 +267,9 @@ private void generateAspectCreateChain(PrintWriter out, for (int i = 0; i < infraDeps.size(); i++) { var infra = infraDeps.get(i); var infraVarName = infra.parameterName(); - out.println(" .flatMap(" + infraPrevVar + " -> " + generateInfraStoreCall(infra) - + ")"); + out.println(" .flatMap(" + infraPrevVar + " -> " + generateInfraStoreCall(infra)); infraPrevVar = infraVarName; + openInfraFlatMaps++; } } // Handle slice dependencies @@ -341,7 +342,14 @@ private void generateAspectCreateChain(PrintWriter out, out.println(" return aspect.apply(new " + wrapperName + "(" + String.join(", ", wrappedArgs) + "));"); - out.println(" });"); + // Close the map + var closeIndent = " "; + out.println(closeIndent + "})"); + // Close all open infra flatMaps + for (int i = 0; i < openInfraFlatMaps; i++) { + out.println(closeIndent + ")"); + } + out.println(closeIndent.substring(4) + ";"); } /** @@ -517,6 +525,8 @@ private void generateCreateSliceMethod(PrintWriter out, SliceModel model) { ? "," : ""; var escapedMethodName = escapeJavaString(method.name()); + // Note: MethodName.unwrap() is safe here because method names are validated + // at annotation processing time per RFC-0001 out.println(" new SliceMethod<>("); out.println(" MethodName.methodName(\"" + escapedMethodName + "\").unwrap(),"); out.println(" delegate::" + method.name() + ","); @@ -646,14 +656,15 @@ private void generateMixedDependencyChain(PrintWriter out, if (!infraDeps.isEmpty()) { var firstInfra = infraDeps.getFirst(); out.println(" return " + generateInfraStoreCall(firstInfra)); - // Chain remaining infra deps + // Chain remaining infra deps - keep flatMaps open for variable scoping var indent = " "; + var openFlatMaps = 0; for (int i = 1; i < infraDeps.size(); i++) { var infra = infraDeps.get(i); var prevInfra = infraDeps.get(i - 1); - out.println(indent + ".flatMap(" + prevInfra.parameterName() + " -> " + generateInfraStoreCall(infra) - + ")"); + out.println(indent + ".flatMap(" + prevInfra.parameterName() + " -> " + generateInfraStoreCall(infra)); indent += " "; + openFlatMaps++; } // Chain slice dependency proxies if any if (!sliceDeps.isEmpty()) { @@ -669,12 +680,14 @@ private void generateMixedDependencyChain(PrintWriter out, var firstHandle = allSliceHandles.getFirst(); out.println(indent + ".flatMap(" + lastInfra.parameterName() + " -> " + generateMethodHandleCall(firstHandle)); indent += " "; + openFlatMaps++; // Remaining handles use previous handle's varName for (int i = 1; i < allSliceHandles.size(); i++) { var handle = allSliceHandles.get(i); var prevHandle = allSliceHandles.get(i - 1); out.println(indent + ".flatMap(" + prevHandle.varName() + " -> " + generateMethodHandleCall(handle)); indent += " "; + openFlatMaps++; } // Final map with all dependencies var lastHandle = allSliceHandles.getLast(); @@ -682,8 +695,8 @@ private void generateMixedDependencyChain(PrintWriter out, generateDependencyInstantiation(out, indent, sliceDeps, proxyMethodsCache); generateFactoryCall(out, indent, model); out.println(indent + "})"); - // Close all flatMaps - for (int i = 0; i < infraDeps.size() - 1 + allSliceHandles.size(); i++) { + // Close all open flatMaps + for (int i = 0; i < openFlatMaps; i++) { indent = indent.substring(4); out.println(indent + ")"); } @@ -694,8 +707,8 @@ private void generateMixedDependencyChain(PrintWriter out, out.println(indent + ".map(" + lastInfra.parameterName() + " -> {"); generateFactoryCall(out, indent, model); out.println(indent + "})"); - // Close all flatMaps - for (int i = 0; i < infraDeps.size() - 1; i++) { + // Close all open flatMaps + for (int i = 0; i < openFlatMaps; i++) { indent = indent.substring(4); out.println(indent + ")"); }