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 <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 06:42:57 +02:00
parent 7799f8ce5f
commit 32fe96041c
3 changed files with 89 additions and 15 deletions

View File

@@ -140,7 +140,9 @@ public class HtmlExporter implements StateMachineExporter {
} }
if (chain.getMatchedTransitions() != null && !chain.getMatchedTransitions().isEmpty()) { if (chain.getMatchedTransitions() != null && !chain.getMatchedTransitions().isEmpty()) {
LinkResolution resolution = chain.getLinkResolution(); LinkResolution resolution = chain.getLinkResolution();
return resolution == null || resolution == LinkResolution.RESOLVED; return resolution == null
|| resolution == LinkResolution.RESOLVED
|| resolution == LinkResolution.AMBIGUOUS_WIDEN;
} }
return false; return false;
} }

View File

@@ -377,12 +377,21 @@
return c.linkResolution === 'RESOLVED' || c.linkResolution == null; 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) { function isUnresolvedExternalChain(c) {
return c.linkResolution === 'UNRESOLVED_EXTERNAL'; return c.linkResolution === 'UNRESOLVED_EXTERNAL';
} }
function isSidebarChain(c) { function isSidebarChain(c) {
return isLifecycleChain(c) || isResolvedChain(c) || isUnresolvedExternalChain(c); return isLifecycleChain(c) || isHighlightableChain(c) || isUnresolvedExternalChain(c);
} }
function chainsForEntryPoint(ep) { function chainsForEntryPoint(ep) {
@@ -397,6 +406,10 @@
return chainsForEntryPoint(ep).filter(isResolvedChain); return chainsForEntryPoint(ep).filter(isResolvedChain);
} }
function highlightableChainsForEntryPoint(ep) {
return chainsForEntryPoint(ep).filter(isHighlightableChain);
}
function populateSidebar() { function populateSidebar() {
const list = document.getElementById('ep-list'); const list = document.getElementById('ep-list');
if (!list) return; if (!list) return;
@@ -421,12 +434,15 @@
if (lifecycleChain) { if (lifecycleChain) {
const label = formatLifecycleLabel(lifecycleChain.triggerPoint.event); const label = formatLifecycleLabel(lifecycleChain.triggerPoint.event);
triggersHtml += `<span class="badge lifecycle" style="font-size: 0.65rem; padding: 2px 6px; background:#ecfdf5; color:#047857; border:1px solid #a7f3d0;">${label}</span>`; triggersHtml += `<span class="badge lifecycle" style="font-size: 0.65rem; padding: 2px 6px; background:#ecfdf5; color:#047857; border:1px solid #a7f3d0;">${label}</span>`;
} else if (chains.some(isUnresolvedExternalChain) && !chains.some(isResolvedChain)) { } else if (chains.some(isUnresolvedExternalChain) && !chains.some(isHighlightableChain)) {
triggersHtml += `<span class="badge unresolved-external" style="font-size: 0.65rem; padding: 2px 6px; background:#fff7ed; color:#c2410c; border:1px solid #fed7aa;">Unresolved external</span>`; triggersHtml += `<span class="badge unresolved-external" style="font-size: 0.65rem; padding: 2px 6px; background:#fff7ed; color:#c2410c; border:1px solid #fed7aa;">Unresolved external</span>`;
} else { } else {
if (chains.some(isAmbiguousWidenChain)) {
triggersHtml += `<span class="badge ambiguous-widen" style="font-size: 0.65rem; padding: 2px 6px; background:#fefce8; color:#a16207; border:1px solid #fde68a; margin-right:4px;">Ambiguous link</span>`;
}
triggersHtml += 'Triggers: '; triggersHtml += 'Triggers: ';
const addedEvents = new Set(); const addedEvents = new Set();
chains.filter(isResolvedChain).forEach(c => { chains.filter(isHighlightableChain).forEach(c => {
c.matchedTransitions.forEach(t => { c.matchedTransitions.forEach(t => {
const evName = t.event || ""; const evName = t.event || "";
if (!addedEvents.has(evName)) { if (!addedEvents.has(evName)) {
@@ -470,7 +486,7 @@
function highlightEntryPoint(ep) { function highlightEntryPoint(ep) {
clearHighlights(); clearHighlights();
const chains = resolvedChainsForEntryPoint(ep); const chains = highlightableChainsForEntryPoint(ep);
const seen = new Set(); const seen = new Set();
let steps = []; let steps = [];
@@ -479,7 +495,12 @@
const key = buildLinkKey(t.sourceState, t.event); const key = buildLinkKey(t.sourceState, t.event);
if (!key || seen.has(key)) return; if (!key || seen.has(key)) return;
seen.add(key); 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)
});
}); });
}); });

View File

@@ -283,6 +283,12 @@ class HtmlTransitionLinkConsistencyTest {
.className("com.example.web.OrderController") .className("com.example.web.OrderController")
.methodName("suspend") .methodName("suspend")
.build(); .build();
EntryPoint ambiguousEp = EntryPoint.builder()
.type(EntryPoint.Type.CUSTOM)
.name("ambiguous")
.className("com.example.web.AmbiguousController")
.methodName("trigger")
.build();
CallChain resolvedChain = CallChain.builder() CallChain resolvedChain = CallChain.builder()
.entryPoint(resolvedEp) .entryPoint(resolvedEp)
@@ -318,12 +324,7 @@ class HtmlTransitionLinkConsistencyTest {
.build()) .build())
.build(); .build();
CallChain ambiguousChain = CallChain.builder() CallChain ambiguousChain = CallChain.builder()
.entryPoint(EntryPoint.builder() .entryPoint(ambiguousEp)
.type(EntryPoint.Type.CUSTOM)
.name("ambiguous")
.className("com.example.web.AmbiguousController")
.methodName("trigger")
.build())
.linkResolution(LinkResolution.AMBIGUOUS_WIDEN) .linkResolution(LinkResolution.AMBIGUOUS_WIDEN)
.triggerPoint(TriggerPoint.builder() .triggerPoint(TriggerPoint.builder()
.event("com.example.order.OrderEvent.PAY") .event("com.example.order.OrderEvent.PAY")
@@ -347,7 +348,7 @@ class HtmlTransitionLinkConsistencyTest {
.startStates(Set.of("com.example.order.OrderState.NEW")) .startStates(Set.of("com.example.order.OrderState.NEW"))
.endStates(Set.of("com.example.order.OrderState.PAID")) .endStates(Set.of("com.example.order.OrderState.PAID"))
.metadata(CodebaseMetadata.builder() .metadata(CodebaseMetadata.builder()
.entryPoints(List.of(resolvedEp, unresolvedEp, noMatchEp)) .entryPoints(List.of(resolvedEp, unresolvedEp, noMatchEp, ambiguousEp))
.callChains(List.of(resolvedChain, unresolvedChain, noMatchChain, ambiguousChain)) .callChains(List.of(resolvedChain, unresolvedChain, noMatchChain, ambiguousChain))
.build()) .build())
.build(); .build();
@@ -355,13 +356,63 @@ class HtmlTransitionLinkConsistencyTest {
String html = new HtmlExporter().export(result, ExportOptions.builder().includeMetadataPane(true).build()); String html = new HtmlExporter().export(result, ExportOptions.builder().includeMetadataPane(true).build());
assertThat(html).contains("function isResolvedChain(c)"); 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 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("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 === 'UNRESOLVED_EXTERNAL'");
assertThat(html).contains("linkResolution === 'RESOLVED'"); assertThat(html).contains("linkResolution === 'RESOLVED'");
assertThat(html).contains("linkResolution === 'AMBIGUOUS_WIDEN'");
assertThat(html).contains("id=\"sidebar\""); 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. */ /** Mirrors {@code template.html} link-key derivation for regression checks. */