transition enricher

This commit is contained in:
2026-06-18 23:02:39 +02:00
parent d93d36e8ad
commit 8d4ba0697e
20 changed files with 845 additions and 57 deletions

View File

@@ -0,0 +1,96 @@
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.MatchedTransition;
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.State;
import click.kamil.springstatemachineexporter.model.Transition;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
public class TransitionLinkerEnricher implements AnalysisEnricher {
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (result.getMetadata() == null || result.getMetadata().getCallChains() == null) {
return;
}
List<CallChain> updatedChains = new ArrayList<>();
List<Transition> stateMachineTransitions = result.getTransitions();
for (CallChain chain : result.getMetadata().getCallChains()) {
TriggerPoint tp = chain.getTriggerPoint();
if (tp == null || tp.getEvent() == null) {
updatedChains.add(chain);
continue;
}
String triggerEvent = simplify(tp.getEvent());
String triggerSource = tp.getSourceState() != null ? simplify(tp.getSourceState()) : null;
List<MatchedTransition> matched = new ArrayList<>();
for (Transition t : stateMachineTransitions) {
if (t.getEvent() != null) {
String smEventRaw = t.getEvent().fullIdentifier() != null ? t.getEvent().fullIdentifier() : t.getEvent().rawName();
String smEvent = simplify(smEventRaw);
if (smEvent.equals(triggerEvent)) {
// Event matches. Check source state if provided
for (State smSourceState : t.getSourceStates()) {
String smSourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName();
String smSource = simplify(smSourceRaw);
if (triggerSource == null || triggerSource.equals(smSource)) {
for (State smTargetState : t.getTargetStates()) {
String sourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName();
String targetRaw = smTargetState.fullIdentifier() != null ? smTargetState.fullIdentifier() : smTargetState.rawName();
matched.add(MatchedTransition.builder()
.sourceState(sourceRaw)
.targetState(targetRaw)
.event(smEventRaw)
.build());
}
}
}
}
}
}
// Create a new CallChain with the matched transitions
CallChain updatedChain = CallChain.builder()
.entryPoint(chain.getEntryPoint())
.methodChain(chain.getMethodChain())
.triggerPoint(chain.getTriggerPoint())
.contextMachineId(chain.getContextMachineId())
.matchedTransitions(matched)
.build();
updatedChains.add(updatedChain);
}
// Update the metadata with the new call chains
CodebaseMetadata updatedMetadata = CodebaseMetadata.builder()
.triggers(result.getMetadata().getTriggers())
.entryPoints(result.getMetadata().getEntryPoints())
.callChains(updatedChains)
.properties(result.getMetadata().getProperties())
.build();
result.setMetadata(updatedMetadata);
}
private String simplify(String name) {
if (name == null) return "";
int dot = name.lastIndexOf('.');
if (dot >= 0) {
return name.substring(dot + 1);
}
return name;
}
}

View File

@@ -15,4 +15,6 @@ public class CallChain {
private final EntryPoint entryPoint;
private final List<String> methodChain; // e.g., ["Controller.submit", "Service.process", "Service.send"]
private final TriggerPoint triggerPoint;
private final String contextMachineId;
private final List<MatchedTransition> matchedTransitions;
}

View File

@@ -0,0 +1,16 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder;
import lombok.Data;
import lombok.extern.jackson.Jacksonized;
@Data
@Builder
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class MatchedTransition {
private final String sourceState;
private final String targetState;
private final String event;
}

View File

@@ -18,5 +18,6 @@ public class TriggerPoint {
private final String sourceFile;
private final String sourceModule;
private final String stateMachineId; // Optional: to link to a specific SM instance
private final String sourceState; // Optional: if we can determine the expected current state
private final int lineNumber;
}

View File

@@ -39,10 +39,12 @@ public class CallGraphBuilder {
List<String> path = findPath(startMethod, targetMethod, callGraph, new HashSet<>());
if (path != null) {
TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph);
String contextMachineId = extractContextMachineId(path, callGraph);
chains.add(CallChain.builder()
.entryPoint(ep)
.triggerPoint(resolvedTp)
.methodChain(path)
.contextMachineId(contextMachineId)
.build());
}
}
@@ -97,6 +99,24 @@ public class CallGraphBuilder {
return tp;
}
private String extractContextMachineId(List<String> path, Map<String, List<CallEdge>> callGraph) {
for (String node : path) {
List<CallEdge> edges = callGraph.get(node);
if (edges != null) {
for (CallEdge edge : edges) {
String target = edge.getTargetMethod();
if (target != null && (target.contains(".restore") || target.contains(".read"))) {
// Persister signatures usually like: restore(stateMachine, contextObj)
if (edge.getArguments().size() >= 2) {
return edge.getArguments().get(1); // The contextObj / machineId
}
}
}
}
}
return null;
}
private Map<String, List<CallEdge>> buildCallGraph() {
graph = new HashMap<>();
for (CompilationUnit cu : context.getCompilationUnits()) {
@@ -131,13 +151,15 @@ public class CallGraphBuilder {
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
String calledMethod = null;
if (superTd != null) {
String calledMethod = resolveMethodInType(superTd, methodName);
if (calledMethod != null) {
List<String> args = resolveArguments(node.arguments());
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args));
}
calledMethod = resolveMethodInType(superTd, methodName);
}
if (calledMethod == null) {
calledMethod = superFqn + "." + methodName;
}
List<String> args = resolveArguments(node.arguments());
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args));
}
}
}
@@ -153,8 +175,8 @@ public class CallGraphBuilder {
for (Object argObj : astArguments) {
Expression expr = (Expression) argObj;
String val = constantResolver.resolve(expr, context);
if (val == null && expr instanceof SimpleName sn) {
val = sn.getIdentifier();
if (val == null) {
val = expr.toString(); // Fallback to raw string (handles Enums, 'new Class()', etc)
}
args.add(val);
}
@@ -217,22 +239,25 @@ public class CallGraphBuilder {
private List<String> findPath(String start, String target, Map<String, List<CallEdge>> graph, Set<String> visited) {
if (start.equals(target)) return new ArrayList<>(List.of(start));
if (!visited.add(start)) return null;
if (!visited.add(start)) return null; // Path-scoped cycle detection
List<CallEdge> neighbors = graph.get(start);
if (neighbors != null) {
for (CallEdge edge : neighbors) {
String neighbor = edge.getTargetMethod();
if (neighbor.equals(target) || isHeuristicMatch(neighbor, target)) {
visited.remove(start);
return new ArrayList<>(List.of(start, target));
}
List<String> path = findPath(neighbor, target, graph, visited);
if (path != null) {
path.add(0, start);
visited.remove(start);
return path;
}
}
}
visited.remove(start);
return null;
}

View File

@@ -33,7 +33,8 @@ public class GenericEventDetector {
public boolean visit(MethodInvocation node) {
String methodName = node.getName().getIdentifier();
if ("sendEvent".equals(methodName)) {
if ("sendEvent".equals(methodName) || "sendEvents".equals(methodName) ||
"sendEventCollect".equals(methodName) || "sendEventMono".equals(methodName)) {
processSendEvent(node, cu, triggers);
}
@@ -124,8 +125,11 @@ public class GenericEventDetector {
if (type == null) return null;
String sourceState = extractSourceState(node);
return TriggerPoint.builder()
.event(eventValue)
.sourceState(sourceState)
.className(context.getFqn(type))
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
.sourceFile(context.getRelativePath(context.getFqn(type)))
@@ -133,15 +137,105 @@ public class GenericEventDetector {
.build();
}
private String extractSourceState(ASTNode node) {
ASTNode current = node.getParent();
while (current != null && !(current instanceof MethodDeclaration)) {
if (current instanceof IfStatement ifStmt) {
Expression expr = ifStmt.getExpression();
String state = extractStateFromExpression(expr);
if (state != null) return state;
} else if (current instanceof SwitchCase switchCase) {
if (!switchCase.expressions().isEmpty()) {
return getSimpleNameString((Expression) switchCase.expressions().get(0));
}
} else if (current instanceof SwitchStatement switchStmt) {
// If it's a switch block but we haven't hit a SwitchCase directly (AST hierarchy usually has SwitchCase as siblings of block statements)
// Actually, walking up from the node, we will hit the SwitchStatement, but we need the SwitchCase right before us in the block.
ASTNode parentBlock = current;
// It's complex to walk siblings. A simpler heuristic is to just use IfStatement and SwitchCase (if wrapped correctly).
}
current = current.getParent();
}
return null;
}
private String extractStateFromExpression(Expression expr) {
if (expr instanceof InfixExpression infix) {
if (infix.getOperator() == InfixExpression.Operator.EQUALS) {
Expression left = infix.getLeftOperand();
Expression right = infix.getRightOperand();
// Usually one is a method call like getState(), the other is an enum
if (left instanceof QualifiedName || left instanceof SimpleName && !(right instanceof SimpleName)) {
return getSimpleNameString(left);
}
if (right instanceof QualifiedName || right instanceof SimpleName && !(left instanceof SimpleName)) {
return getSimpleNameString(right);
}
return getSimpleNameString(right); // Fallback
}
} else if (expr instanceof MethodInvocation mi) {
if ("equals".equals(mi.getName().getIdentifier()) && !mi.arguments().isEmpty()) {
return getSimpleNameString((Expression) mi.arguments().get(0));
}
}
return null;
}
private String getSimpleNameString(Expression expr) {
if (expr instanceof QualifiedName qn) {
return qn.getName().getIdentifier();
} else if (expr instanceof FieldAccess fa) {
return fa.getName().getIdentifier();
} else if (expr instanceof StringLiteral sl) {
return sl.getLiteralValue();
}
return expr.toString();
}
private String extractEventFromMessageBuilder(Expression expr) {
if (expr instanceof SimpleName sn) {
String varName = sn.getIdentifier();
MethodDeclaration enclosingMethod = findEnclosingMethod(expr);
if (enclosingMethod != null) {
// Find variable declaration
final Expression[] initializer = new Expression[1];
enclosingMethod.accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
initializer[0] = node.getInitializer();
}
return super.visit(node);
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
initializer[0] = node.getRightHandSide();
}
return super.visit(node);
}
});
if (initializer[0] != null) {
return extractEventFromMessageBuilder(initializer[0]); // recursive
}
}
}
if (!(expr instanceof MethodInvocation mi)) return null;
// Trace back chain: MessageBuilder.withPayload(event).setHeader(...).build()
MethodInvocation current = mi;
while (current != null) {
String name = current.getName().getIdentifier();
if ("withPayload".equals(name) && !current.arguments().isEmpty()) {
if (("withPayload".equals(name) || "just".equals(name)) && !current.arguments().isEmpty()) {
Expression payloadExpr = (Expression) current.arguments().get(0);
// If it's Mono.just(msg), where msg is a variable
if ("just".equals(name)) {
String extracted = extractEventFromMessageBuilder(payloadExpr);
if (extracted != null) return extracted;
}
String resolved = constantResolver.resolve(payloadExpr, context);
if (resolved != null) return resolved;

View File

@@ -126,7 +126,7 @@ public class PlantUml implements StateMachineExporter {
String label = buildLabel(t, options);
if (options.isEmbedIdentifiers()) {
String eventStr = t.getEvent() != null ? options.formatEvent(t.getEvent()) : null;
String linkId = eventStr != null && !eventStr.isBlank() ? "link_" + normalize(eventStr) : "link_anon_" + normalize(source) + "_" + normalize(target);
String linkId = eventStr != null && !eventStr.isBlank() ? "link_" + normalize(source) + "__" + normalize(eventStr) : "link_anon_" + normalize(source) + "_" + normalize(target);
// Force a label even if empty to ensure the link group exists in SVG
String displayLabel = label.isEmpty() ? " " : label;
// Brackets [...] in the label break PlantUML link syntax [[url label]],

View File

@@ -44,7 +44,8 @@ public class ExportService {
new TriggerEnricher(),
new EntryPointEnricher(),
new PropertyEnricher(),
new CallChainEnricher()
new CallChainEnricher(),
new click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher()
)));
}
@@ -212,6 +213,11 @@ public class ExportService {
Set<String> endStates = TransitionStateUtils.findEndStates(transitions, endStatesAst);
Set<click.kamil.springstatemachineexporter.model.State> allStates = TransitionStateUtils.findAllStates(transitions, initialStatesAst, endStatesAst);
if (allStates.isEmpty() && transitions.isEmpty()) {
log.info("Skipping empty state machine config: {}", className);
return;
}
AnalysisResult result = AnalysisResult.builder()
.name(className)
.states(allStates)
@@ -243,6 +249,11 @@ public class ExportService {
Set<String> endStates = TransitionStateUtils.findEndStates(transitions, null);
Set<click.kamil.springstatemachineexporter.model.State> allStates = TransitionStateUtils.findAllStates(transitions, null, null);
if (allStates.isEmpty() && transitions.isEmpty()) {
log.info("Skipping empty state machine bean: {}", uniqueName);
return;
}
AnalysisResult result = AnalysisResult.builder()
.name(uniqueName)
.states(allStates)