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()) {
LinkResolution resolution = chain.getLinkResolution();
return resolution == null || resolution == LinkResolution.RESOLVED;
return resolution == null
|| resolution == LinkResolution.RESOLVED
|| resolution == LinkResolution.AMBIGUOUS_WIDEN;
}
return false;
}

View File

@@ -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 += `<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>`;
} 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: ';
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)
});
});
});

View File

@@ -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. */