2 Commits

Author SHA1 Message Date
12183693aa cursor 2026-07-07 21:15:12 +02:00
1be8d5e950 aaa 2026-07-07 21:11:32 +02:00
6 changed files with 169 additions and 45 deletions

View File

@@ -14,6 +14,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import click.kamil.springstatemachineexporter.exporter.ExportOptions;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
@@ -21,6 +22,8 @@ import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictF
public class TransitionLinkerEnricher implements AnalysisEnricher {
private static final ExportOptions LINK_FORMAT_OPTIONS = ExportOptions.builder().build();
private final BeanResolutionEngine routingEngine = new HeuristicBeanResolutionEngine();
private final EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine();
@@ -47,17 +50,16 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
for (Transition t : stateMachineTransitions) {
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)) {
String smEventRaw = t.getEvent().fullIdentifier() != null ? t.getEvent().fullIdentifier() : t.getEvent().rawName();
// Event matches
String smEventForLink = formatEventForLink(t.getEvent());
for (State smSourceState : t.getSourceStates()) {
String smSourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName();
String smSource = simplify(smSourceRaw);
String smSourceForLink = formatSourceForLink(smSourceState);
String smSource = simplify(smSourceForLink);
if (triggerSource == null || triggerSource.equals(smSource)) {
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
MatchedTransition mt = MatchedTransition.builder()
.sourceState(smSourceRaw)
.targetState(smSourceRaw)
.event(smEventRaw)
.sourceState(smSourceForLink)
.targetState(smSourceForLink)
.event(smEventForLink)
.build();
if (isRoutedToCorrectMachine(chain, result.getName(), context)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
@@ -65,11 +67,11 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
}
} else {
for (State smTargetState : t.getTargetStates()) {
String targetRaw = smTargetState.fullIdentifier() != null ? smTargetState.fullIdentifier() : smTargetState.rawName();
String targetForLink = formatSourceForLink(smTargetState);
MatchedTransition mt = MatchedTransition.builder()
.sourceState(smSourceRaw)
.targetState(targetRaw)
.event(smEventRaw)
.sourceState(smSourceForLink)
.targetState(targetForLink)
.event(smEventForLink)
.build();
if (isRoutedToCorrectMachine(chain, result.getName(), context)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
@@ -215,6 +217,22 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
return simplified;
}
private String formatSourceForLink(State state) {
if (state == null) return null;
return simplifyForLink(LINK_FORMAT_OPTIONS.formatState(state));
}
private String formatEventForLink(click.kamil.springstatemachineexporter.model.Event event) {
if (event == null) return null;
return LINK_FORMAT_OPTIONS.formatEvent(event);
}
private String simplifyForLink(String name) {
if (name == null) return "";
if (name.contains("\"")) return name;
return name.replaceAll("[^a-zA-Z0-9_.]", "");
}
private boolean isGuardedContains(String smEvent, String triggerEvent) {
if (smEvent == null || triggerEvent == null) return false;

View File

@@ -85,8 +85,8 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
if (triggerEvent == null || smEvent == null) return false;
if (triggerEvent.equals(smEvent)) return true;
String tConst = triggerEvent.contains(".") ? triggerEvent.substring(triggerEvent.lastIndexOf('.') + 1) : triggerEvent;
String smConst = smEvent.contains(".") ? smEvent.substring(smEvent.lastIndexOf('.') + 1) : smEvent;
String tConst = constantName(triggerEvent);
String smConst = constantName(smEvent);
if (!tConst.equals(smConst)) return false;
@@ -100,20 +100,46 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
return false;
}
if (eventTypeFqn != null && eventTypeFqn.contains(".") && smEvent.contains(".")) {
String fullTriggerFqn = eventTypeFqn + "." + tConst;
return fullTriggerFqn.equals(smEvent);
if (smEvent.contains(".")) {
String smEnumType = smEvent.substring(0, smEvent.lastIndexOf('.'));
if (eventTypeFqn != null) {
return enumTypesMatch(eventTypeFqn, smEnumType);
}
if (triggerEvent.contains(".")) {
String triggerEnumType = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
return enumTypesMatch(triggerEnumType, smEnumType);
}
// Bare constant name (e.g. after Enum.name()) with no type context must not match every enum
return false;
}
if (triggerEvent.contains(".") && smEvent.contains(".")) {
String tClass = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
String smClass = smEvent.substring(0, smEvent.lastIndexOf('.'));
String tClassSimple = tClass.contains(".") ? tClass.substring(tClass.lastIndexOf('.') + 1) : tClass;
String smClassSimple = smClass.contains(".") ? smClass.substring(smClass.lastIndexOf('.') + 1) : smClass;
return tClassSimple.equals(smClassSimple);
}
// Both sides are unqualified identifiers (string/primitive events)
return !triggerEvent.contains(".");
}
return true;
private String constantName(String event) {
return event.contains(".") ? event.substring(event.lastIndexOf('.') + 1) : event;
}
private boolean enumTypesMatch(String type1, String type2) {
if (type1 == null || type2 == null) return false;
if (type1.equals(type2)) return true;
if (type1.endsWith("." + type2) || type2.endsWith("." + type1)) return true;
String simple1 = simpleName(type1);
String simple2 = simpleName(type2);
if (!simple1.equals(simple2)) return false;
// Same simple name only matches when at least one side is package-less (import-style reference).
// Prevents com.foo.OrderEvents from matching com.bar.OrderEvents.
return !type1.contains(".") || !type2.contains(".");
}
private String simpleName(String name) {
return name.contains(".") ? name.substring(name.lastIndexOf('.') + 1) : name;
}
private boolean isStringTypeOrPrimitive(String typeFqn) {

View File

@@ -143,7 +143,38 @@ class TransitionLinkerEnricherTest {
assertThat(updatedChain.getMatchedTransitions()).hasSize(1);
}
@Test
void shouldNotLinkAllEnumTransitionsWhenConstantNamesCollide() {
Transition orderPay = new Transition();
orderPay.setSourceStates(List.of(State.of("NEW", "NEW")));
orderPay.setTargetStates(List.of(State.of("PAID", "PAID")));
orderPay.setEvent(Event.of("PAY", "com.example.OrderEvents.PAY"));
Transition invoicePay = new Transition();
invoicePay.setSourceStates(List.of(State.of("DRAFT", "DRAFT")));
invoicePay.setTargetStates(List.of(State.of("SENT", "SENT")));
invoicePay.setEvent(Event.of("PAY", "com.example.InvoiceEvents.PAY"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("OrderEvents.PAY")
.eventTypeFqn("com.example.OrderEvents")
.build())
.contextMachineId("testMachine")
.build();
AnalysisResult result = AnalysisResult.builder()
.name("testMachine")
.transitions(List.of(orderPay, invoicePay))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).hasSize(1);
assertThat(updatedChain.getMatchedTransitions().get(0).getEvent()).isEqualTo("OrderEvents.PAY");
}
@Test
void shouldMatchBaseEventDespiteScrapedPolyEvents() {

View File

@@ -196,6 +196,38 @@ class StrictFqnMatchingEngineTest {
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldNotMatchBareStrippedConstantToAnyEnumWithoutTypeContext() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("PAY")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldNotMatchSameEnumSimpleNameFromDifferentPackages() {
Event smEvent = Event.of("PAY", "com.example.other.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("OrderEvents.PAY")
.eventTypeFqn("com.example.OrderEvents")
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldNotMatchPolymorphicStrippedConstantWithoutTypeContext() {
Event smEvent = Event.of("PAY", "com.example.InvoiceEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder()
.event("getType()")
.polymorphicEvents(List.of("PAY"))
.build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldMatchDynamicMethodCallAsVariable() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");

View File

@@ -429,11 +429,15 @@
clearHighlights();
const chains = metadata.metadata.callChains || [];
const seen = new Set();
let steps = [];
chains.forEach(c => {
if (c.entryPoint.className === ep.className && c.entryPoint.methodName === ep.methodName) {
if (c.matchedTransitions && c.matchedTransitions.length > 0) {
c.matchedTransitions.forEach(t => {
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 });
});
}
@@ -459,22 +463,22 @@
});
steps.forEach(step => {
let targetEvent = "";
let targetSource = "";
let targetLinkKey = "";
let targetAnon = "";
let isAmbiguous = false;
if (typeof step === 'object') {
targetEvent = normalize(step.event);
targetSource = normalize(step.source);
targetLinkKey = buildLinkKey(step.source, step.event);
isAmbiguous = step.ambiguous;
} else if (step.includes("->")) {
const parts = step.split("->");
targetAnon = "#link_anon_" + normalize(parts[0]) + "_" + normalize(parts[1]);
} else {
targetEvent = normalize(step);
targetLinkKey = "__" + normalize(formatForLinkId(step));
}
if (!targetLinkKey && !targetAnon) return;
svg.querySelectorAll('a').forEach(link => {
const href = link.getAttribute('xlink:href') || link.getAttribute('href');
if (!href) return;
@@ -482,14 +486,11 @@
let matches = false;
if (targetAnon) {
matches = (href === targetAnon);
} else if (targetEvent) {
if (targetSource) {
// Exact match
matches = (href === "#link_" + targetSource + "__" + targetEvent);
} else {
// Match all sources for this event
matches = href.startsWith("#link_") && href.endsWith("__" + targetEvent);
}
} else if (targetLinkKey.includes("__")) {
matches = (href === "#link_" + targetLinkKey);
} else {
// Business-flow step: event name only, match that event from any source
matches = href.startsWith("#link_") && href.endsWith(targetLinkKey);
}
if (matches) {
@@ -570,8 +571,7 @@
if (!t.event) return false;
const evStr = t.event.fullIdentifier || t.event.rawName || t.event;
const sourceStr = t.sourceStates && t.sourceStates.length > 0 ? (t.sourceStates[0].fullIdentifier || t.sourceStates[0].rawName) : "";
const expectedLinkId = normalize(sourceStr) + "__" + normalize(evStr);
return expectedLinkId === linkId;
return buildLinkKey(sourceStr, evStr) === linkId;
});
if (metaTrans && metaTrans.event) {
cleanEventName = metaTrans.event.fullIdentifier || metaTrans.event.rawName || metaTrans.event;
@@ -602,17 +602,37 @@
});
}
function formatForLinkId(identifier) {
if (!identifier) return "";
const s = String(identifier);
const lastDot = s.lastIndexOf('.');
if (lastDot <= 0) return s;
const prevDot = s.lastIndexOf('.', lastDot - 1);
return prevDot > 0 ? s.substring(prevDot + 1) : s;
}
function normalize(s) {
if (!s) return "";
return s.replace(/[^a-zA-Z0-9]/g, '_');
}
function buildLinkKey(source, event) {
if (!event) return "";
const formattedEvent = formatForLinkId(event);
if (!source) return "__" + normalize(formattedEvent);
return normalize(formatForLinkId(source)) + "__" + normalize(formattedEvent);
}
function eventsMatchForLink(a, b) {
return normalize(formatForLinkId(a)) === normalize(formatForLinkId(b));
}
function createTooltip(name, trans) {
const chains = (metadata.metadata.callChains || []).filter(c => {
if (!c.matchedTransitions || c.matchedTransitions.length === 0) return false;
return c.matchedTransitions.some(t => {
const cEventStr = typeof t.event === 'object' ? (t.event.fullIdentifier || t.event.rawName || t.event) : t.event;
return normalize(cEventStr) === normalize(name);
return eventsMatchForLink(cEventStr, name);
});
});

View File

@@ -156,10 +156,7 @@ public class HtmlExporterTest {
String html = exporter.export(result, ExportOptions.builder().build());
// Verify that the template gracefully unwraps the Event JSON object for interactive hovers in attachInteractivity
assertThat(html).contains("const evStr = t.event.fullIdentifier || t.event.rawName || t.event;");
// Verify that the template gracefully unwraps the Event JSON object when filtering call chains in createTooltip
assertThat(html).contains("const cEventStr = typeof t.event === 'object' ? (t.event.fullIdentifier || t.event.rawName || t.event) : t.event;");
assertThat(html).contains("function formatForLinkId(identifier)");
assertThat(html).contains("function buildLinkKey(source, event)");
}
}