From cc2cf1845b60eb454271b92fd235d71b7dc7b258 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Sun, 12 Jul 2026 11:38:55 +0200 Subject: [PATCH] Fix identifier consistency on JSON re-export and after property resolution. Co-authored-by: Cursor --- .../analysis/model/AnalysisResult.java | 33 +++++++++ .../service/AnalysisResultFinalizer.java | 18 ++++- .../service/ExportService.java | 4 +- .../JsonRoundTripCanonicalizationTest.java | 71 +++++++++++++++++++ 4 files changed, 123 insertions(+), 3 deletions(-) diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/AnalysisResult.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/AnalysisResult.java index c10beda..c9f38f3 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/AnalysisResult.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/AnalysisResult.java @@ -7,6 +7,8 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata; import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer; +import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver; import click.kamil.springstatemachineexporter.model.Transition; import lombok.Builder; import lombok.Getter; @@ -117,6 +119,37 @@ public class AnalysisResult { .properties(metadata.getProperties()) .build(); } + + canonicalizeResolvedIdentifiers(); + } + + private void canonicalizeResolvedIdentifiers() { + if (stateTypeFqn == null && eventTypeFqn == null) { + return; + } + StateMachineTypeResolver.MachineTypes machineTypes = + new StateMachineTypeResolver.MachineTypes(stateTypeFqn, eventTypeFqn); + MachineEnumCanonicalizer.canonicalizeTransitions(transitions, machineTypes); + if (states != null) { + this.states = MachineEnumCanonicalizer.canonicalizeStates(states, stateTypeFqn); + } + if (startStates != null) { + startStates = MachineEnumCanonicalizer.canonicalizeStateLabels(startStates, stateTypeFqn); + } + if (endStates != null) { + endStates = MachineEnumCanonicalizer.canonicalizeStateLabels(endStates, stateTypeFqn); + } + if (metadata != null && metadata.getTriggers() != null) { + List canonicalTriggers = metadata.getTriggers().stream() + .map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes)) + .collect(java.util.stream.Collectors.toList()); + metadata = CodebaseMetadata.builder() + .triggers(canonicalTriggers) + .entryPoints(metadata.getEntryPoints()) + .callChains(metadata.getCallChains()) + .properties(metadata.getProperties()) + .build(); + } } private static TriggerPoint resolveTrigger( diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AnalysisResultFinalizer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AnalysisResultFinalizer.java index 32d6e25..98eb9fd 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AnalysisResultFinalizer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AnalysisResultFinalizer.java @@ -59,7 +59,23 @@ public final class AnalysisResultFinalizer { result.setEventTypeFqn(machineTypes.eventTypeFqn()); } - private static void applyCanonicalization( + /** + * Canonicalizes transitions, states, triggers, and matched transitions without validation. + * Safe to call before transition relinking when machine types are known. + */ + public static void applyCanonicalization( + AnalysisResult result, + StateMachineTypeResolver.MachineTypes machineTypes) { + if (result == null || machineTypes == null) { + return; + } + if (machineTypes.stateTypeFqn() == null && machineTypes.eventTypeFqn() == null) { + return; + } + applyCanonicalizationFields(result, machineTypes); + } + + private static void applyCanonicalizationFields( AnalysisResult result, StateMachineTypeResolver.MachineTypes machineTypes) { MachineEnumCanonicalizer.canonicalizeTransitions(result.getTransitions(), machineTypes); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java index 5c89e4b..7b67b7c 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java @@ -187,14 +187,14 @@ public class ExportService { log.info("JSON re-export: no source project found; using embedded machine types if present"); } - enrichmentService.relinkAfterPropertyResolution(result, context, null); - StateMachineTypeResolver.MachineTypes machineTypes = click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory.resolveMachineTypes( result.getName(), result.getStateTypeFqn(), result.getEventTypeFqn(), context); + AnalysisResultFinalizer.applyCanonicalization(result, machineTypes); + enrichmentService.relinkAfterPropertyResolution(result, context, null); AnalysisResultFinalizer.finalizeResult(result, context, machineTypes); generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JsonRoundTripCanonicalizationTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JsonRoundTripCanonicalizationTest.java index 2ed4d47..79130bb 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JsonRoundTripCanonicalizationTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JsonRoundTripCanonicalizationTest.java @@ -1,6 +1,7 @@ package click.kamil.springstatemachineexporter.analysis.service; import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; +import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.exporter.ExportOptions; @@ -16,6 +17,7 @@ import org.junit.jupiter.api.io.TempDir; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -95,6 +97,75 @@ class JsonRoundTripCanonicalizationTest { .isEqualTo("com.example.order.OrderEvent.PAY"); } + @Test + void shouldRelinkMatchedTransitionsAfterPreCanonicalizingShortFormJson(@TempDir Path tempDir) throws Exception { + writeSampleProject(tempDir); + + TriggerPoint trigger = TriggerPoint.builder() + .event("OrderEvent.PAY") + .sourceState("OrderState.NEW") + .className("com.example.web.OrderController") + .methodName("pay") + .sourceFile("OrderController.java") + .build(); + CallChain chain = CallChain.builder() + .triggerPoint(trigger) + .build(); + + AnalysisResult shortForm = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .stateTypeFqn("com.example.order.OrderState") + .eventTypeFqn("com.example.order.OrderEvent") + .transitions(List.of(shortTransition( + "OrderEvent.PAY", + "OrderState.NEW", + "OrderState.PAID"))) + .metadata(CodebaseMetadata.builder() + .triggers(List.of(trigger)) + .callChains(List.of(chain)) + .build()) + .build(); + + Path jsonFile = tempDir.resolve("machine.json"); + Files.writeString(jsonFile, new JsonExporter().export(shortForm, ExportOptions.builder().build())); + + Path outputDir = tempDir.resolve("out"); + ExportService exportService = new ExportService(List.of(new JsonExporter())); + exportService.runJsonExporter(jsonFile, outputDir, List.of("json")); + + AnalysisResult roundTripped = new JsonImportService().importAnalysisResult( + outputDir.resolve("com.example.config.OrderStateMachineConfiguration") + .resolve("com.example.config.OrderStateMachineConfiguration.json")); + + assertThat(roundTripped.getMetadata().getCallChains()).hasSize(1); + assertThat(roundTripped.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1); + assertThat(roundTripped.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getEvent()) + .isEqualTo("com.example.order.OrderEvent.PAY"); + assertThat(roundTripped.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getSourceState()) + .isEqualTo("com.example.order.OrderState.NEW"); + } + + @Test + void shouldDedupeStatesByFullIdentifierAfterPropertyResolution(@TempDir Path tempDir) throws Exception { + writeSampleProject(tempDir); + + AnalysisResult result = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .stateTypeFqn("com.example.order.OrderState") + .eventTypeFqn("com.example.order.OrderEvent") + .states(Set.of( + State.of("OrderState.NEW", "OrderState.NEW"), + State.of("UNDEFINED", "com.example.order.OrderState.NEW"))) + .build(); + + result.applyResolution(Map.of()); + + assertThat(result.getStates()).hasSize(1); + State only = result.getStates().iterator().next(); + assertThat(only.fullIdentifier()).isEqualTo("com.example.order.OrderState.NEW"); + assertThat(only.rawName()).isEqualTo("OrderState.NEW"); + } + @Test void shouldFindProjectRootFromJsonDirectory(@TempDir Path tempDir) throws Exception { Path projectRoot = tempDir.resolve("my-app");