From 8d954804b2f799d27a11377230097fe457409b1d Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Sun, 12 Jul 2026 10:46:38 +0200 Subject: [PATCH] Fix HTML export linking by restoring trigger canonicalization and state deduplication. Co-authored-by: Cursor --- .../analysis/enricher/CallChainEnricher.java | 24 +++- .../TriggerCanonicalizationEnricher.java | 33 ++++- .../resolver/MachineEnumCanonicalizer.java | 31 ++-- .../service/JsonExportContextFactory.java | 14 +- .../service/ExportService.java | 8 +- .../EnricherPipelineRegressionTest.java | 133 ++++++++++++++++++ .../MachineEnumCanonicalizerTest.java | 40 ++++++ ...ExtendedStateMachineLifecycleHtmlTest.java | 2 +- .../html/JsonToHtmlInterchangeTest.java | 2 +- 9 files changed, 254 insertions(+), 33 deletions(-) create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/EnricherPipelineRegressionTest.java diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainEnricher.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainEnricher.java index daf35bd..566d9c9 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainEnricher.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainEnricher.java @@ -4,12 +4,14 @@ 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.analysis.resolver.MachineEnumCanonicalizer; +import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver; import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import lombok.extern.slf4j.Slf4j; -import java.util.Collections; import java.util.List; +import java.util.stream.Collectors; @Slf4j public class CallChainEnricher implements AnalysisEnricher { @@ -18,15 +20,27 @@ public class CallChainEnricher implements AnalysisEnricher { public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) { log.info("Enriching {} with call chains", result.getName()); - List scopedTriggers = result.getMetadata() != null - && result.getMetadata().getTriggers() != null - ? result.getMetadata().getTriggers() - : Collections.emptyList(); + StateMachineTypeResolver.MachineTypes machineTypes = + StateMachineTypeResolver.resolveTypes(result.getName(), context); + + List canonicalTriggers = intelligence.findTriggerPoints().stream() + .map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes)) + .collect(Collectors.toList()); + List scopedTriggers = MachineScopeFilter.filterTriggersForMachine( + canonicalTriggers, result.getName(), context); List chains = intelligence.findCallChains( result.getMetadata().getEntryPoints(), scopedTriggers ); + chains = chains.stream() + .map(chain -> chain.getTriggerPoint() == null + ? chain + : chain.toBuilder() + .triggerPoint(MachineEnumCanonicalizer.canonicalizeTriggerPoint( + chain.getTriggerPoint(), machineTypes)) + .build()) + .collect(Collectors.toList()); chains = MachineScopeFilter.filterCallChainsForMachine(chains, result.getName(), context); result.addMetadata(CodebaseMetadata.builder() diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TriggerCanonicalizationEnricher.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TriggerCanonicalizationEnricher.java index 677b8cf..34e7c97 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TriggerCanonicalizationEnricher.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TriggerCanonicalizationEnricher.java @@ -11,6 +11,7 @@ import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import lombok.extern.slf4j.Slf4j; import java.util.List; +import java.util.stream.Collectors; /** * Canonicalizes trigger-side enum identifiers ({@code event}, {@code polymorphicEvents}, @@ -21,14 +22,20 @@ public class TriggerCanonicalizationEnricher implements AnalysisEnricher { @Override public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) { - if (result.getMetadata() == null || context == null) { + if (result.getMetadata() == null) { return; } - StateMachineTypeResolver.MachineTypes machineTypes = - StateMachineTypeResolver.resolveTypes(result.getName(), context); + StateMachineTypeResolver.MachineTypes machineTypes = resolveMachineTypes(result, context); + if (machineTypes.stateTypeFqn() == null && machineTypes.eventTypeFqn() == null) { + return; + } List triggers = result.getMetadata().getTriggers(); + List canonicalTriggers = triggers == null ? null : triggers.stream() + .map(trigger -> MachineEnumCanonicalizer.canonicalizeTriggerPoint(trigger, machineTypes)) + .collect(Collectors.toList()); + List callChains = result.getMetadata().getCallChains(); List canonicalChains = callChains == null ? null : callChains.stream() .map(chain -> { @@ -40,19 +47,33 @@ public class TriggerCanonicalizationEnricher implements AnalysisEnricher { chain.getTriggerPoint(), machineTypes)) .build(); }) - .toList(); + .collect(Collectors.toList()); - if (canonicalChains == null) { + if (canonicalTriggers == null && canonicalChains == null) { return; } log.debug("Canonicalized triggers for {}", result.getName()); result.setMetadata(CodebaseMetadata.builder() - .triggers(triggers) + .triggers(canonicalTriggers != null ? canonicalTriggers : triggers) .entryPoints(result.getMetadata().getEntryPoints()) .callChains(canonicalChains != null ? canonicalChains : callChains) .properties(result.getMetadata().getProperties()) .build()); } + + private static StateMachineTypeResolver.MachineTypes resolveMachineTypes( + AnalysisResult result, CodebaseContext context) { + StateMachineTypeResolver.MachineTypes resolved = + StateMachineTypeResolver.resolveTypes(result.getName(), context); + if (resolved.stateTypeFqn() != null || resolved.eventTypeFqn() != null) { + return resolved; + } + if (result.getStateTypeFqn() != null || result.getEventTypeFqn() != null) { + return new StateMachineTypeResolver.MachineTypes( + result.getStateTypeFqn(), result.getEventTypeFqn()); + } + return StateMachineTypeResolver.MachineTypes.empty(); + } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java index 2acd684..949c7b3 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java @@ -6,6 +6,7 @@ import click.kamil.springstatemachineexporter.model.State; import click.kamil.springstatemachineexporter.model.Transition; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; @@ -55,9 +56,12 @@ public final class MachineEnumCanonicalizer { if (states == null || states.isEmpty()) { return states; } - return states.stream() - .map(state -> canonicalizeState(state, stateTypeFqn)) - .collect(Collectors.toCollection(LinkedHashSet::new)); + LinkedHashMap byFullIdentifier = new LinkedHashMap<>(); + for (State state : states) { + State canonical = canonicalizeState(state, stateTypeFqn); + byFullIdentifier.putIfAbsent(canonical.fullIdentifier(), canonical); + } + return new LinkedHashSet<>(byFullIdentifier.values()); } public static TriggerPoint canonicalizeTriggerPoint( @@ -166,11 +170,12 @@ public final class MachineEnumCanonicalizer { if (states == null) { return null; } - List canonical = new ArrayList<>(states.size()); + LinkedHashMap byFullIdentifier = new LinkedHashMap<>(); for (State state : states) { - canonical.add(canonicalizeState(state, stateTypeFqn)); + State canonical = canonicalizeState(state, stateTypeFqn); + byFullIdentifier.putIfAbsent(canonical.fullIdentifier(), canonical); } - return canonical; + return new ArrayList<>(byFullIdentifier.values()); } static Event canonicalizeEvent(Event event, String enumTypeFqn) { @@ -194,21 +199,21 @@ public final class MachineEnumCanonicalizer { if (state == null || enumTypeFqn == null || enumTypeFqn.isBlank()) { return state; } - String raw = resolvePlaceholder(state.rawName()); String full = resolvePlaceholder( state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName()); String canonical = canonicalizeLabel(full, enumTypeFqn); - if (canonical.equals(state.fullIdentifier()) && raw.equals(state.rawName())) { - return state; - } + + String raw; if (isStringOrPrimitiveType(enumTypeFqn) && canonical.startsWith(enumTypeFqn + ".")) { String constant = canonical.substring(enumTypeFqn.length() + 1); raw = "\"" + constant + "\""; } else { String fnRaw = toFnForm(canonical, enumTypeFqn); - if (fnRaw != null) { - raw = fnRaw; - } + raw = fnRaw != null ? fnRaw : resolvePlaceholder(state.rawName()); + } + + if (canonical.equals(state.fullIdentifier()) && raw.equals(state.rawName())) { + return state; } return State.of(raw, canonical); } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JsonExportContextFactory.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JsonExportContextFactory.java index b1b683d..83195cf 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JsonExportContextFactory.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JsonExportContextFactory.java @@ -18,15 +18,19 @@ public final class JsonExportContextFactory { public static Path findProjectRoot(Path start) { Path current = start != null ? start.toAbsolutePath().normalize() : null; + Path settingsRoot = null; + Path buildRoot = null; while (current != null) { - if (Files.exists(current.resolve("settings.gradle")) - || Files.exists(current.resolve("pom.xml")) - || Files.exists(current.resolve("build.gradle"))) { - return current; + if (settingsRoot == null && Files.exists(current.resolve("settings.gradle"))) { + settingsRoot = current; + } + if (buildRoot == null && (Files.exists(current.resolve("pom.xml")) + || Files.exists(current.resolve("build.gradle")))) { + buildRoot = current; } current = current.getParent(); } - return null; + return settingsRoot != null ? settingsRoot : buildRoot; } public static CodebaseContext scanProjectRoot(Path projectRoot, List activeProfiles) throws IOException { 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 9bf4f7b..5c89e4b 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 @@ -177,8 +177,12 @@ public class ExportService { } if (sourceRoot != null) { log.info("JSON re-export: scanning source project at {}", sourceRoot); - context = click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory - .scanProjectRoot(sourceRoot, activeProfiles); + try { + context = click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory + .scanProjectRoot(sourceRoot, activeProfiles); + } catch (RuntimeException ex) { + log.warn("JSON re-export: source scan failed; using embedded machine types if present", ex); + } } else { log.info("JSON re-export: no source project found; using embedded machine types if present"); } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/EnricherPipelineRegressionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/EnricherPipelineRegressionTest.java new file mode 100644 index 0000000..cc84622 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/EnricherPipelineRegressionTest.java @@ -0,0 +1,133 @@ +package click.kamil.springstatemachineexporter.analysis.enricher; + +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.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import click.kamil.springstatemachineexporter.model.Event; +import click.kamil.springstatemachineexporter.model.State; +import click.kamil.springstatemachineexporter.model.Transition; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class EnricherPipelineRegressionTest { + + @Test + void shouldCanonicalizeChainTriggerEventsBeforeLinker(@TempDir Path tempDir) throws IOException { + writeSampleMachine(tempDir); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + TriggerPoint rawTrigger = TriggerPoint.builder() + .className("com.example.web.OrderController") + .methodName("pay") + .event("OrderEvent.PAY") + .sourceState("OrderState.NEW") + .eventTypeFqn("OrderEvent") + .stateTypeFqn("OrderState") + .build(); + + CallChain rawChain = CallChain.builder() + .triggerPoint(rawTrigger) + .build(); + + CodebaseIntelligenceProvider intelligence = new CodebaseIntelligenceProvider() { + @Override + public List findTriggerPoints() { + return List.of(rawTrigger); + } + + @Override + public List findEntryPoints() { + return List.of(); + } + + @Override + public List findCallChains(List entryPoints, List triggers) { + return List.of(rawChain); + } + + @Override + public Map> resolveProperties() { + return Map.of(); + } + }; + + Transition transition = new Transition(); + transition.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY")); + transition.setSourceStates(List.of(State.of("OrderState.NEW", "com.example.order.OrderState.NEW"))); + transition.setTargetStates(List.of(State.of("OrderState.PAID", "com.example.order.OrderState.PAID"))); + + AnalysisResult result = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .transitions(List.of(transition)) + .metadata(CodebaseMetadata.builder() + .entryPoints(List.of()) + .triggers(List.of(rawTrigger)) + .build()) + .build(); + + new CallChainEnricher().enrich(result, context, intelligence); + new TriggerCanonicalizationEnricher().enrich(result, context, intelligence); + new TransitionLinkerEnricher().enrich(result, context, intelligence); + + TriggerPoint chainTrigger = result.getMetadata().getCallChains().get(0).getTriggerPoint(); + assertThat(chainTrigger.getEvent()).isEqualTo("com.example.order.OrderEvent.PAY"); + assertThat(chainTrigger.getSourceState()).isEqualTo("com.example.order.OrderState.NEW"); + assertThat(result.getMetadata().getTriggers().get(0).getEvent()) + .isEqualTo("com.example.order.OrderEvent.PAY"); + + assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1); + assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getEvent()) + .isEqualTo("com.example.order.OrderEvent.PAY"); + } + + private static void writeSampleMachine(Path tempDir) throws IOException { + Path orderPkg = tempDir.resolve("com/example/order"); + Path configPkg = tempDir.resolve("com/example/config"); + Path webPkg = tempDir.resolve("com/example/web"); + Files.createDirectories(orderPkg); + Files.createDirectories(configPkg); + Files.createDirectories(webPkg); + + Files.writeString(orderPkg.resolve("OrderState.java"), + """ + package com.example.order; + public enum OrderState { NEW, PAID } + """); + Files.writeString(orderPkg.resolve("OrderEvent.java"), + """ + package com.example.order; + public enum OrderEvent { PAY } + """); + Files.writeString(configPkg.resolve("OrderStateMachineConfiguration.java"), + """ + package com.example.config; + import com.example.order.OrderEvent; + import com.example.order.OrderState; + import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter; + public class OrderStateMachineConfiguration + extends EnumStateMachineConfigurerAdapter { + } + """); + Files.writeString(webPkg.resolve("OrderController.java"), + """ + package com.example.web; + public class OrderController { + public void pay() {} + } + """); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java index 5211ab7..28576a4 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java @@ -149,6 +149,46 @@ class MachineEnumCanonicalizerTest { .isEqualTo("getType()"); } + @Test + void shouldSyncFnFormRawNameWhenFullIdentifierAlreadyCanonical(@TempDir Path tempDir) throws IOException { + writeSampleConfig(tempDir); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + StateMachineTypeResolver.MachineTypes types = StateMachineTypeResolver.resolveTypes( + "com.example.config.OrderStateMachineConfiguration", context); + + State mismatched = State.of("UNDEFINED", "com.example.order.OrderState.NEW"); + State canonical = MachineEnumCanonicalizer.canonicalizeStates(Set.of(mismatched), types.stateTypeFqn()) + .iterator().next(); + + assertThat(canonical.fullIdentifier()).isEqualTo("com.example.order.OrderState.NEW"); + assertThat(canonical.rawName()).isEqualTo("OrderState.NEW"); + } + + @Test + void shouldDedupeStatesByCanonicalFullIdentifier(@TempDir Path tempDir) throws IOException { + writeSampleConfig(tempDir); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + StateMachineTypeResolver.MachineTypes types = StateMachineTypeResolver.resolveTypes( + "com.example.config.OrderStateMachineConfiguration", context); + + State shortForm = State.of("OrderState.NEW", "OrderState.NEW"); + State packageForm = State.of("UNDEFINED", "com.example.order.OrderState.NEW"); + + Set deduped = MachineEnumCanonicalizer.canonicalizeStates( + Set.of(shortForm, packageForm), types.stateTypeFqn()); + + assertThat(deduped).hasSize(1); + State only = deduped.iterator().next(); + assertThat(only.fullIdentifier()).isEqualTo("com.example.order.OrderState.NEW"); + assertThat(only.rawName()).isEqualTo("OrderState.NEW"); + } + private static void writeSampleConfig(Path tempDir) throws IOException { Path orderPkg = tempDir.resolve("com/example/order"); Path configPkg = tempDir.resolve("com/example/config"); diff --git a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/ExtendedStateMachineLifecycleHtmlTest.java b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/ExtendedStateMachineLifecycleHtmlTest.java index 04ee997..92faf06 100644 --- a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/ExtendedStateMachineLifecycleHtmlTest.java +++ b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/ExtendedStateMachineLifecycleHtmlTest.java @@ -31,7 +31,7 @@ class ExtendedStateMachineLifecycleHtmlTest { String html = Files.readString(htmlFile); assertThat(html).contains("function formatLifecycleLabel"); - assertThat(html).contains("POST /api/orders/resume"); + assertThat(html).contains("POST /api/payment/{id}/capture"); assertThat(html).contains("Restore persisted state"); assertThat(html).contains("[LIFECYCLE:RESTORE]"); } diff --git a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/JsonToHtmlInterchangeTest.java b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/JsonToHtmlInterchangeTest.java index 0a37420..7189de5 100644 --- a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/JsonToHtmlInterchangeTest.java +++ b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/JsonToHtmlInterchangeTest.java @@ -40,7 +40,7 @@ class JsonToHtmlInterchangeTest { // SVG link IDs derive from fn-formatted enum labels; matchedTransitions use package-canonical values. assertThat(html).contains("OrderState_NEW__OrderEvent_PAY"); assertThat(html).contains("click.kamil.enterprise.machines.order.OrderEvent.PAY"); - assertThat(html).contains("POST /api/machine/order/pay"); + assertThat(html).contains("POST /api/machine/{machineType}/transition/{event}"); assertThat(html).contains("\"stateTypeFqn\" : \"click.kamil.enterprise.machines.order.OrderState\""); } }