From ffda97489e5d21a95cee8742877e5c1947f62334 Mon Sep 17 00:00:00 2001 From: Jonathan Schneider Date: Tue, 12 May 2026 11:03:35 -0400 Subject: [PATCH] Add `DevCenter.getSpec()` returning a versioned JSON description The Moderne CLI bundles `rewrite-devcenter` in its fat jar so it can construct a `DevCenter` from a recipe and walk its cards/measures to render dashboards. That bundling causes class-identity splits across the CLI's recipe classloader boundary (e.g. `SemverRowBuilder$ParserHolder` fails to initialize because the parent loader can't see `VersionParser` from `rewrite-java-dependencies`) and shadows whatever `rewrite-devcenter` version customer custom devcenters bring in. `getSpec()` lets the CLI consume DevCenter structure as JSON instead of walking the Java API across classloaders. The CLI reflectively constructs `DevCenter`, calls `getSpec()`, and parses the result into CLI-owned DTOs - no shared class identity needed. Schema (v1): - `apiVersion`: stable wire-format discriminator - `upgradesAndMigrations`: ordered cards, each with name, fixRecipeId, measure names - `security`: nullable security card with the same shape --- .../java/io/moderne/devcenter/DevCenter.java | 52 +++++++++++++++++++ .../io/moderne/devcenter/DevCenterTest.java | 52 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/src/main/java/io/moderne/devcenter/DevCenter.java b/src/main/java/io/moderne/devcenter/DevCenter.java index 8b363c6..5ca4ba1 100644 --- a/src/main/java/io/moderne/devcenter/DevCenter.java +++ b/src/main/java/io/moderne/devcenter/DevCenter.java @@ -15,6 +15,8 @@ */ package io.moderne.devcenter; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import io.moderne.devcenter.table.SecurityIssues; import io.moderne.devcenter.table.UpgradesAndMigrations; import lombok.EqualsAndHashCode; @@ -29,6 +31,7 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; @@ -122,6 +125,55 @@ public Card getCard(String name) { throw new IllegalArgumentException("No card found with name: " + name); } + /** + * Returns a stable JSON description of this DevCenter's structure for + * cross-classloader consumption by tools (e.g. the Moderne CLI). The + * format carries an {@code apiVersion} so consumers can detect schema + * compatibility. Schema (v1): + *
{@code
+     * {
+     *   "apiVersion": "v1",
+     *   "upgradesAndMigrations": [
+     *     {"name": "...", "fixRecipeId": "...", "measures": ["...", ...]},
+     *     ...
+     *   ],
+     *   "security": {"name": "...", "fixRecipeId": "...", "measures": [...]} | null
+     * }
+     * }
+ * Card and measure ordering is preserved. + */ + public String getSpec() { + Map spec = new LinkedHashMap<>(); + spec.put("apiVersion", "v1"); + + List> upgrades = new ArrayList<>(); + for (Card card : getUpgradesAndMigrations()) { + upgrades.add(cardToSpec(card)); + } + spec.put("upgradesAndMigrations", upgrades); + + Card securityCard = getSecurity(); + spec.put("security", securityCard == null ? null : cardToSpec(securityCard)); + + try { + return new ObjectMapper().writeValueAsString(spec); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize DevCenter spec", e); + } + } + + private static Map cardToSpec(Card card) { + Map map = new LinkedHashMap<>(); + map.put("name", card.getName()); + map.put("fixRecipeId", card.getFixRecipeId()); + List measureNames = new ArrayList<>(); + for (DevCenterMeasure m : card.getMeasures()) { + measureNames.add(m.getName()); + } + map.put("measures", measureNames); + return map; + } + private List getUpgradesAndMigrationsRecursive(Recipe recipe, List upgradesAndMigrations) { try { Class umcClass = Class.forName( diff --git a/src/test/java/io/moderne/devcenter/DevCenterTest.java b/src/test/java/io/moderne/devcenter/DevCenterTest.java index a7f1ee7..21ed284 100644 --- a/src/test/java/io/moderne/devcenter/DevCenterTest.java +++ b/src/test/java/io/moderne/devcenter/DevCenterTest.java @@ -15,6 +15,8 @@ */ package io.moderne.devcenter; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import de.siegmar.fastcsv.reader.CommentStrategy; import de.siegmar.fastcsv.reader.CsvReader; import de.siegmar.fastcsv.reader.NamedCsvRecord; @@ -41,6 +43,7 @@ import java.nio.charset.StandardCharsets; 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.stream.Stream; @@ -132,6 +135,55 @@ void validateStandAloneDevCenterRecipe() throws Exception { devCenter.validate(); } + @Test + void getSpecMatchesStarterDevCenter() throws Exception { + var devCenter = new DevCenter(starterDevCenter); + JsonNode spec = new ObjectMapper().readTree(devCenter.getSpec()); + + assertThat(spec.get("apiVersion").asText()).isEqualTo("v1"); + + JsonNode upgrades = spec.get("upgradesAndMigrations"); + assertThat(upgrades.isArray()).isTrue(); + assertThat(upgrades.size()).isEqualTo(3); + + JsonNode firstCard = upgrades.get(0); + assertThat(firstCard.get("name").asText()).isEqualTo(devCenter.getUpgradesAndMigrations().getFirst().getName()); + List measureNames = new ArrayList<>(); + firstCard.get("measures").forEach(m -> measureNames.add(m.asText())); + assertThat(measureNames).containsExactly("Major", "Minor", "Patch", "Completed"); + + JsonNode security = spec.get("security"); + assertThat(security.isNull()).isFalse(); + assertThat(security.get("name").asText()).isEqualTo(devCenter.getSecurity().getName()); + List securityMeasures = new ArrayList<>(); + security.get("measures").forEach(m -> securityMeasures.add(m.asText())); + assertThat(securityMeasures).contains("Zip slip"); + } + + @Test + void getSpecOmitsSecurityWhenAbsent() throws Exception { + //language=yaml + var recipe = """ + type: specs.openrewrite.org/v1beta/recipe + name: io.moderne.devcenter.JavaOnly + displayName: Just an upgrade card + description: Upgrade Java version + recipeList: + - io.moderne.devcenter.JavaVersionUpgrade: + majorVersion: 21 + """; + Recipe r = Environment.builder() + .load(new YamlResourceLoader(new ByteArrayInputStream(recipe.getBytes(StandardCharsets.UTF_8)), + URI.create("rewrite.yml"), new Properties())) + .build() + .activateRecipes("io.moderne.devcenter.JavaOnly"); + + JsonNode spec = new ObjectMapper().readTree(new DevCenter(r).getSpec()); + assertThat(spec.get("apiVersion").asText()).isEqualTo("v1"); + assertThat(spec.get("upgradesAndMigrations").size()).isEqualTo(1); + assertThat(spec.get("security").isNull()).isTrue(); + } + @Test void uniqueCardNames() { //language=yaml