From 32fe96041cd6dcda088f9afd0df4108dc5bae6a1 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Tue, 14 Jul 2026 06:42:57 +0200 Subject: [PATCH] Surface ambiguous widen chains in HTML explorer AMBIGUOUS_WIDEN call chains with matched transitions now appear in the sidebar with an Ambiguous link badge, use dashed highlight styling, and participate in endpoint hover when link keys are available. Co-authored-by: Cursor --- .../html/exporter/HtmlExporter.java | 4 +- .../src/main/resources/html/template.html | 31 +++++++-- .../HtmlTransitionLinkConsistencyTest.java | 69 ++++++++++++++++--- 3 files changed, 89 insertions(+), 15 deletions(-) diff --git a/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/exporter/HtmlExporter.java b/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/exporter/HtmlExporter.java index 1ca84c3..7528055 100644 --- a/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/exporter/HtmlExporter.java +++ b/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/exporter/HtmlExporter.java @@ -140,7 +140,9 @@ public class HtmlExporter implements StateMachineExporter { } if (chain.getMatchedTransitions() != null && !chain.getMatchedTransitions().isEmpty()) { LinkResolution resolution = chain.getLinkResolution(); - return resolution == null || resolution == LinkResolution.RESOLVED; + return resolution == null + || resolution == LinkResolution.RESOLVED + || resolution == LinkResolution.AMBIGUOUS_WIDEN; } return false; } diff --git a/state_machine_exporter_html/src/main/resources/html/template.html b/state_machine_exporter_html/src/main/resources/html/template.html index dfe6d97..71cc5db 100644 --- a/state_machine_exporter_html/src/main/resources/html/template.html +++ b/state_machine_exporter_html/src/main/resources/html/template.html @@ -377,12 +377,21 @@ return c.linkResolution === 'RESOLVED' || c.linkResolution == null; } + function isAmbiguousWidenChain(c) { + return c.linkResolution === 'AMBIGUOUS_WIDEN' + && c.matchedTransitions && c.matchedTransitions.length > 0; + } + + function isHighlightableChain(c) { + return isResolvedChain(c) || isAmbiguousWidenChain(c); + } + function isUnresolvedExternalChain(c) { return c.linkResolution === 'UNRESOLVED_EXTERNAL'; } function isSidebarChain(c) { - return isLifecycleChain(c) || isResolvedChain(c) || isUnresolvedExternalChain(c); + return isLifecycleChain(c) || isHighlightableChain(c) || isUnresolvedExternalChain(c); } function chainsForEntryPoint(ep) { @@ -397,6 +406,10 @@ return chainsForEntryPoint(ep).filter(isResolvedChain); } + function highlightableChainsForEntryPoint(ep) { + return chainsForEntryPoint(ep).filter(isHighlightableChain); + } + function populateSidebar() { const list = document.getElementById('ep-list'); if (!list) return; @@ -421,12 +434,15 @@ if (lifecycleChain) { const label = formatLifecycleLabel(lifecycleChain.triggerPoint.event); triggersHtml += `${label}`; - } else if (chains.some(isUnresolvedExternalChain) && !chains.some(isResolvedChain)) { + } else if (chains.some(isUnresolvedExternalChain) && !chains.some(isHighlightableChain)) { triggersHtml += `Unresolved external`; } else { + if (chains.some(isAmbiguousWidenChain)) { + triggersHtml += `Ambiguous link`; + } triggersHtml += 'Triggers: '; const addedEvents = new Set(); - chains.filter(isResolvedChain).forEach(c => { + chains.filter(isHighlightableChain).forEach(c => { c.matchedTransitions.forEach(t => { const evName = t.event || ""; if (!addedEvents.has(evName)) { @@ -470,7 +486,7 @@ function highlightEntryPoint(ep) { clearHighlights(); - const chains = resolvedChainsForEntryPoint(ep); + const chains = highlightableChainsForEntryPoint(ep); const seen = new Set(); let steps = []; @@ -479,7 +495,12 @@ const key = buildLinkKey(t.sourceState, t.event); if (!key || seen.has(key)) return; seen.add(key); - steps.push({ event: t.event, source: t.sourceState, ambiguous: c.triggerPoint && c.triggerPoint.ambiguous }); + steps.push({ + event: t.event, + source: t.sourceState, + ambiguous: c.linkResolution === 'AMBIGUOUS_WIDEN' + || (c.triggerPoint && c.triggerPoint.ambiguous) + }); }); }); diff --git a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlTransitionLinkConsistencyTest.java b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlTransitionLinkConsistencyTest.java index 3368f53..dfd0831 100644 --- a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlTransitionLinkConsistencyTest.java +++ b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlTransitionLinkConsistencyTest.java @@ -283,6 +283,12 @@ class HtmlTransitionLinkConsistencyTest { .className("com.example.web.OrderController") .methodName("suspend") .build(); + EntryPoint ambiguousEp = EntryPoint.builder() + .type(EntryPoint.Type.CUSTOM) + .name("ambiguous") + .className("com.example.web.AmbiguousController") + .methodName("trigger") + .build(); CallChain resolvedChain = CallChain.builder() .entryPoint(resolvedEp) @@ -318,12 +324,7 @@ class HtmlTransitionLinkConsistencyTest { .build()) .build(); CallChain ambiguousChain = CallChain.builder() - .entryPoint(EntryPoint.builder() - .type(EntryPoint.Type.CUSTOM) - .name("ambiguous") - .className("com.example.web.AmbiguousController") - .methodName("trigger") - .build()) + .entryPoint(ambiguousEp) .linkResolution(LinkResolution.AMBIGUOUS_WIDEN) .triggerPoint(TriggerPoint.builder() .event("com.example.order.OrderEvent.PAY") @@ -347,7 +348,7 @@ class HtmlTransitionLinkConsistencyTest { .startStates(Set.of("com.example.order.OrderState.NEW")) .endStates(Set.of("com.example.order.OrderState.PAID")) .metadata(CodebaseMetadata.builder() - .entryPoints(List.of(resolvedEp, unresolvedEp, noMatchEp)) + .entryPoints(List.of(resolvedEp, unresolvedEp, noMatchEp, ambiguousEp)) .callChains(List.of(resolvedChain, unresolvedChain, noMatchChain, ambiguousChain)) .build()) .build(); @@ -355,13 +356,63 @@ class HtmlTransitionLinkConsistencyTest { String html = new HtmlExporter().export(result, ExportOptions.builder().includeMetadataPane(true).build()); assertThat(html).contains("function isResolvedChain(c)"); + assertThat(html).contains("function isAmbiguousWidenChain(c)"); + assertThat(html).contains("function isHighlightableChain(c)"); assertThat(html).contains("function isSidebarChain(c)"); - assertThat(html).contains("function resolvedChainsForEntryPoint(ep)"); + assertThat(html).contains("function highlightableChainsForEntryPoint(ep)"); + assertThat(html).contains("Ambiguous link"); + assertThat(html).contains("active-path-ambiguous"); assertThat(html).contains("Unresolved external"); - assertThat(html).contains("const chains = resolvedChainsForEntryPoint(ep)"); + assertThat(html).contains("const chains = highlightableChainsForEntryPoint(ep)"); assertThat(html).contains("linkResolution === 'UNRESOLVED_EXTERNAL'"); assertThat(html).contains("linkResolution === 'RESOLVED'"); + assertThat(html).contains("linkResolution === 'AMBIGUOUS_WIDEN'"); assertThat(html).contains("id=\"sidebar\""); + assertThat(html).contains("#link_OrderState_NEW__OrderEvent_PAY"); + } + + @Test + void ambiguousWidenHtmlShouldExposeMatchedTransitionLinkKeys() { + EntryPoint ambiguousEp = EntryPoint.builder() + .type(EntryPoint.Type.CUSTOM) + .name("ambiguous") + .className("com.example.web.AmbiguousController") + .methodName("trigger") + .build(); + + AnalysisResult result = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .stateTypeFqn("com.example.order.OrderState") + .eventTypeFqn("com.example.order.OrderEvent") + .transitions(List.of(shortTransition( + "com.example.order.OrderEvent.PAY", + "com.example.order.OrderState.NEW", + "com.example.order.OrderState.PAID"))) + .startStates(Set.of("com.example.order.OrderState.NEW")) + .endStates(Set.of("com.example.order.OrderState.PAID")) + .metadata(CodebaseMetadata.builder() + .entryPoints(List.of(ambiguousEp)) + .callChains(List.of(CallChain.builder() + .entryPoint(ambiguousEp) + .linkResolution(LinkResolution.AMBIGUOUS_WIDEN) + .triggerPoint(TriggerPoint.builder() + .event("com.example.order.OrderEvent.PAY") + .ambiguous(true) + .build()) + .matchedTransitions(List.of(MatchedTransition.builder() + .sourceState("com.example.order.OrderState.NEW") + .targetState("com.example.order.OrderState.PAID") + .event("com.example.order.OrderEvent.PAY") + .build())) + .build())) + .build()) + .build(); + + String html = new HtmlExporter().export(result, ExportOptions.builder().includeMetadataPane(true).build()); + + assertThat(html).contains("Ambiguous link"); + assertThat(html).contains("#link_OrderState_NEW__OrderEvent_PAY"); + assertThat(html).contains("com.example.web.AmbiguousController"); } /** Mirrors {@code template.html} link-key derivation for regression checks. */