4 Commits

Author SHA1 Message Date
dd0363cf64 Harden export pipeline identifier consistency and HTML link parity tests.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 11:47:20 +02:00
cc2cf1845b Fix identifier consistency on JSON re-export and after property resolution.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 11:38:55 +02:00
8d954804b2 Fix HTML export linking by restoring trigger canonicalization and state deduplication.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 10:46:38 +02:00
66aaa9563f Refactor analysis pipeline to remove duplicated call-graph and resolver logic.
Retire legacy CallGraphBuilder, unify graph building and accessor resolution through shared helpers, and centralize AST/type utilities to cut drift across engines and enrichers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 10:30:25 +02:00
39 changed files with 1482 additions and 2891 deletions

View File

@@ -1,6 +1,5 @@
package click.kamil.springstatemachineexporter.analysis.enricher;
import click.kamil.springstatemachineexporter.analysis.enricher.MachineScopeFilter;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;

View File

@@ -22,12 +22,14 @@ public class TriggerCanonicalizationEnricher implements AnalysisEnricher {
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (result.getMetadata() == null || context == null) {
if (result.getMetadata() == null) {
return;
}
StateMachineTypeResolver.MachineTypes machineTypes =
StateMachineTypeResolver.resolveTypes(result.getName(), context);
StateMachineTypeResolver.MachineTypes machineTypes = resolveMachineTypes(result, context);
if (machineTypes.stateTypeFqn() == null && machineTypes.eventTypeFqn() == null) {
return;
}
List<TriggerPoint> triggers = result.getMetadata().getTriggers();
List<TriggerPoint> canonicalTriggers = triggers == null ? null : triggers.stream()
@@ -60,4 +62,18 @@ public class TriggerCanonicalizationEnricher implements AnalysisEnricher {
.properties(result.getMetadata().getProperties())
.build());
}
private static StateMachineTypeResolver.MachineTypes resolveMachineTypes(
AnalysisResult result, CodebaseContext context) {
StateMachineTypeResolver.MachineTypes resolved =
StateMachineTypeResolver.resolveTypes(result.getName(), context);
if (resolved.stateTypeFqn() != null || resolved.eventTypeFqn() != null) {
return resolved;
}
if (result.getStateTypeFqn() != null || result.getEventTypeFqn() != null) {
return new StateMachineTypeResolver.MachineTypes(
result.getStateTypeFqn(), result.getEventTypeFqn());
}
return StateMachineTypeResolver.MachineTypes.empty();
}
}

View File

@@ -0,0 +1,47 @@
package click.kamil.springstatemachineexporter.analysis.index;
/**
* Shared JavaBean accessor naming helpers used by analysis and dataflow code.
*/
public final class AccessorNaming {
private AccessorNaming() {
}
public static boolean isBeanStyleAccessorName(String accessorName) {
return (accessorName.startsWith("get") && accessorName.length() > 3)
|| (accessorName.startsWith("is") && accessorName.length() > 2)
|| (accessorName.startsWith("set") && accessorName.length() > 3);
}
public static String propertyNameFromAccessor(String accessorName) {
if (accessorName.startsWith("get") && accessorName.length() > 3) {
return TrivialAccessorDetector.decapitalize(accessorName.substring(3));
}
if (accessorName.startsWith("is") && accessorName.length() > 2) {
return TrivialAccessorDetector.decapitalize(accessorName.substring(2));
}
if (accessorName.startsWith("set") && accessorName.length() > 3) {
return TrivialAccessorDetector.decapitalize(accessorName.substring(3));
}
return accessorName.startsWith("get") ? accessorName.substring(3) : accessorName;
}
public static String setterMethodNameForGetter(String getterName) {
return "set" + capitalizeProperty(propertyNameFromAccessor(getterName));
}
public static String capitalizeProperty(String propertyName) {
if (propertyName == null || propertyName.isEmpty()) {
return propertyName;
}
if (propertyName.length() > 1 && Character.isUpperCase(propertyName.charAt(1))) {
return propertyName;
}
return Character.toUpperCase(propertyName.charAt(0)) + propertyName.substring(1);
}
public static boolean looksLikeIndexedAccessorName(String methodName) {
return methodName.startsWith("get") || methodName.startsWith("is") || methodName.startsWith("set");
}
}

View File

@@ -7,6 +7,7 @@ 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.resolver.StateMachineTypeResolver;
import click.kamil.springstatemachineexporter.model.Transition;
import lombok.Builder;
import lombok.Getter;
@@ -117,6 +118,17 @@ public class AnalysisResult {
.properties(metadata.getProperties())
.build();
}
canonicalizeResolvedIdentifiers();
}
private void canonicalizeResolvedIdentifiers() {
if (stateTypeFqn == null && eventTypeFqn == null) {
return;
}
click.kamil.springstatemachineexporter.analysis.service.AnalysisResultFinalizer.applyCanonicalization(
this,
new StateMachineTypeResolver.MachineTypes(stateTypeFqn, eventTypeFqn));
}
private static TriggerPoint resolveTrigger(

View File

@@ -4,6 +4,7 @@ import click.kamil.springstatemachineexporter.analysis.index.AccessorKind;
import click.kamil.springstatemachineexporter.analysis.index.AccessorFieldResolver;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.index.GetterChainEdge;
import click.kamil.springstatemachineexporter.analysis.pipeline.GetterBodyConstantScanner;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
@@ -400,34 +401,8 @@ public final class AccessorResolver {
}
private List<String> extractConstantGetterReturn(String ownerFqn, String methodName) {
TypeDeclaration typeDeclaration = context.getTypeDeclaration(ownerFqn);
if (typeDeclaration == null) {
return List.of();
}
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
return List.of();
}
List<String> constants = new ArrayList<>();
BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> extractor =
constantExtractor != null
? constantExtractor::extractConstantsFromExpression
: (expr, out) -> {
String resolved = constantResolver.resolve(expr, context);
if (resolved != null) {
out.add(resolved);
}
};
methodDeclaration.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() != null) {
extractor.accept(node.getExpression(), constants);
}
return false;
}
});
return constants;
return GetterBodyConstantScanner.extractConstantGetterReturn(
context, constantExtractor, constantResolver, ownerFqn, methodName);
}
private static String qualifyEnumLikeResult(MethodDeclaration getter, String result) {

View File

@@ -10,6 +10,7 @@ import java.util.concurrent.ConcurrentHashMap;
public final class ClassCompatibilityCache {
private final Map<Map.Entry<String, String>, Boolean> cache = new ConcurrentHashMap<>();
private final Map<String, Object> memoCache = new ConcurrentHashMap<>();
public boolean areClassesCompatible(String classNeighbor, String classTarget, ClassCompatibilityChecker checker) {
if (classNeighbor == null || classTarget == null) {
@@ -22,8 +23,18 @@ public final class ClassCompatibilityCache {
return cache.computeIfAbsent(key, ignored -> checker.check(classNeighbor, classTarget));
}
@SuppressWarnings("unchecked")
public <T> T memoize(String key, java.util.function.Function<String, T> computer) {
return (T) memoCache.computeIfAbsent(key, computer);
}
public void clearMemoCache() {
memoCache.clear();
}
public void clear() {
cache.clear();
memoCache.clear();
}
private static Map.Entry<String, String> cacheKey(String classNeighbor, String classTarget) {

View File

@@ -0,0 +1,77 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.function.BiConsumer;
/**
* Scans trivial getter bodies for constant return values.
*/
public final class GetterBodyConstantScanner {
private GetterBodyConstantScanner() {
}
public static List<String> extractConstantGetterReturn(
CodebaseContext context,
ConstantExtractor constantExtractor,
ConstantResolver constantResolver,
String ownerFqn,
String methodName) {
return extractConstantGetterReturn(context, constantExtractor, constantResolver, ownerFqn, methodName, null, null);
}
public static List<String> extractConstantGetterReturn(
CodebaseContext context,
ConstantExtractor constantExtractor,
ConstantResolver constantResolver,
String ownerFqn,
String methodName,
CompilationUnit contextCu,
Set<String> ignoredVisited) {
TypeDeclaration typeDeclaration = contextCu != null
? context.getTypeDeclaration(ownerFqn, contextCu)
: null;
if (typeDeclaration == null) {
typeDeclaration = context.getTypeDeclaration(ownerFqn);
}
if (typeDeclaration == null) {
return List.of();
}
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
return List.of();
}
List<String> constants = new ArrayList<>();
BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> extractor =
constantExtractor != null
? constantExtractor::extractConstantsFromExpression
: (expr, out) -> {
String resolved = constantResolver.resolve(expr, context);
if (resolved != null) {
out.add(resolved);
}
};
methodDeclaration.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() != null) {
extractor.accept(node.getExpression(), constants);
}
return false;
}
});
return constants;
}
}

View File

@@ -1073,13 +1073,18 @@ public class ConstantResolver {
if (ann instanceof SingleMemberAnnotation sma) {
valueExpr = sma.getValue();
} else if (ann instanceof NormalAnnotation na) {
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if ("value".equals(pair.getName().getIdentifier())) {
valueExpr = pair.getValue();
break;
String raw = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(ann, "value");
if (!raw.isEmpty()) {
if (raw.startsWith("\"") && raw.endsWith("\"")) {
raw = raw.substring(1, raw.length() - 1);
}
if (raw.contains("${")) {
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(
raw, java.util.Collections.emptyMap());
}
return raw;
}
return null;
}
if (valueExpr instanceof StringLiteral sl) {
@@ -1099,11 +1104,7 @@ public class ConstantResolver {
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode current = node;
while (current != null && !(current instanceof TypeDeclaration)) {
current = current.getParent();
}
return (TypeDeclaration) current;
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
}
private MethodDeclaration findInvokedMethod(org.eclipse.jdt.core.dom.MethodInvocation mi, CodebaseContext context) {
@@ -1125,14 +1126,7 @@ public class ConstantResolver {
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null) {
if (parent instanceof MethodDeclaration md) {
return md;
}
parent = parent.getParent();
}
return null;
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
}
private String resolveLocalType(SimpleName sn) {

View File

@@ -6,6 +6,7 @@ import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@@ -55,9 +56,12 @@ public final class MachineEnumCanonicalizer {
if (states == null || states.isEmpty()) {
return states;
}
return states.stream()
.map(state -> canonicalizeState(state, stateTypeFqn))
.collect(Collectors.toCollection(LinkedHashSet::new));
LinkedHashMap<String, State> byFullIdentifier = new LinkedHashMap<>();
for (State state : states) {
State canonical = canonicalizeState(state, stateTypeFqn);
byFullIdentifier.putIfAbsent(canonical.fullIdentifier(), canonical);
}
return new LinkedHashSet<>(byFullIdentifier.values());
}
public static TriggerPoint canonicalizeTriggerPoint(
@@ -166,11 +170,12 @@ public final class MachineEnumCanonicalizer {
if (states == null) {
return null;
}
List<State> canonical = new ArrayList<>(states.size());
LinkedHashMap<String, State> byFullIdentifier = new LinkedHashMap<>();
for (State state : states) {
canonical.add(canonicalizeState(state, stateTypeFqn));
State canonical = canonicalizeState(state, stateTypeFqn);
byFullIdentifier.putIfAbsent(canonical.fullIdentifier(), canonical);
}
return canonical;
return new ArrayList<>(byFullIdentifier.values());
}
static Event canonicalizeEvent(Event event, String enumTypeFqn) {
@@ -194,21 +199,21 @@ public final class MachineEnumCanonicalizer {
if (state == null || enumTypeFqn == null || enumTypeFqn.isBlank()) {
return state;
}
String raw = resolvePlaceholder(state.rawName());
String full = resolvePlaceholder(
state.fullIdentifier() != null ? state.fullIdentifier() : state.rawName());
String canonical = canonicalizeLabel(full, enumTypeFqn);
if (canonical.equals(state.fullIdentifier()) && raw.equals(state.rawName())) {
return state;
}
String raw;
if (isStringOrPrimitiveType(enumTypeFqn) && canonical.startsWith(enumTypeFqn + ".")) {
String constant = canonical.substring(enumTypeFqn.length() + 1);
raw = "\"" + constant + "\"";
} else {
String fnRaw = toFnForm(canonical, enumTypeFqn);
if (fnRaw != null) {
raw = fnRaw;
}
raw = fnRaw != null ? fnRaw : resolvePlaceholder(state.rawName());
}
if (canonical.equals(state.fullIdentifier()) && raw.equals(state.rawName())) {
return state;
}
return State.of(raw, canonical);
}

View File

@@ -1,5 +1,6 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.service.TypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
@@ -204,51 +205,6 @@ public final class StateMachineTypeResolver {
CompilationUnit cu,
CodebaseContext context,
Map<String, String> typeVarBindings) {
if (type == null) {
return null;
}
if (type instanceof WildcardType wildcardType) {
Type bound = wildcardType.getBound();
return bound != null ? resolveTypeToFqn(bound, cu, context, typeVarBindings) : null;
}
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), cu, context, typeVarBindings);
} else {
simpleName = type.toString();
}
if (simpleName.contains("<")) {
simpleName = simpleName.substring(0, simpleName.indexOf('<'));
}
if (typeVarBindings.containsKey(simpleName)) {
return typeVarBindings.get(simpleName);
}
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
if (!packageName.isEmpty()) {
AbstractTypeDeclaration localTd = context.getAbstractTypeDeclaration(packageName + "." + simpleName);
if (localTd != null) {
return context.getFqn(localTd);
}
}
return simpleName;
return new TypeResolver(context).resolveTypeToFqn(type, cu, typeVarBindings);
}
}

View File

@@ -33,6 +33,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
protected final PathBindingEvaluator pathBindingEvaluator;
private final Map<String, ASTNode> parsedNodeCache = new HashMap<>();
private final Map<String, List<String>> polymorphicCallCache = new HashMap<>();
protected Map<String, List<CallEdge>> graph;
private ASTNode parseExpressionString(String expr) {
if (expr == null) return null;
@@ -834,7 +835,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (declaredType != null) {
TypeDeclaration currentTd = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
CompilationUnit cu = currentTd != null && currentTd.getRoot() instanceof CompilationUnit ? (CompilationUnit) currentTd.getRoot() : null;
List<String> values = constantExtractor.resolveMethodReturnConstant(declaredType, methodName, 0, new HashSet<>(), cu);
List<String> values = resolveAccessorConstants(declaredType, methodName, cu);
if (values != null && !values.isEmpty()) {
polymorphicEvents.addAll(values);
}
@@ -870,7 +871,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
TypeDeclaration currentTd = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
if (currentTd != null && context.findMethodDeclaration(currentTd, methodName, true) != null) {
CompilationUnit cu = currentTd.getRoot() instanceof CompilationUnit ? (CompilationUnit) currentTd.getRoot() : null;
List<String> values = constantExtractor.resolveMethodReturnConstant(context.getFqn(currentTd), methodName, 0, new HashSet<>(), cu);
List<String> values = resolveAccessorConstants(context.getFqn(currentTd), methodName, cu);
if (values != null && !values.isEmpty()) {
polymorphicEvents.addAll(values);
sourceMethod = methodFqn;
@@ -905,7 +906,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
for (String type : typesToInspect) {
Set<String> visited = new HashSet<>();
TypeDeclaration baseTd = context.getTypeDeclaration(type);
CompilationUnit cuToUse = null;
if (baseTd != null && baseTd.getRoot() instanceof CompilationUnit) {
@@ -918,7 +918,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
cuToUse = (CompilationUnit) entryTd.getRoot();
}
}
List<String> constants = constantExtractor.resolveMethodReturnConstant(type, methodName, 0, visited, cuToUse, ResolutionBudget.defaults(), false);
List<String> constants = resolveAccessorConstants(type, methodName, cuToUse);
for (String constant : constants) {
if (!polymorphicEvents.contains(constant)) {
polymorphicEvents.add(constant);
@@ -1073,19 +1073,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
protected MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
}
protected TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
}
protected List<String> resolveArguments(List<?> astArguments) {
@@ -1321,33 +1313,24 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
protected String resolveTypeToFqn(Type type, ASTNode contextNode) {
if (type == null) {
return null;
}
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), contextNode);
} else {
simpleName = type.toString();
}
return typeResolver.resolveTypeToFqn(type, contextNode);
}
CompilationUnit cu = (CompilationUnit) contextNode.getRoot();
TypeDeclaration td = context.getTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
protected List<String> resolveAccessorConstants(String ownerFqn, String methodName, CompilationUnit contextCu) {
if (ownerFqn == null || methodName == null) {
return List.of();
}
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
TypeDeclaration ownerType = contextCu != null
? context.getTypeDeclaration(ownerFqn, contextCu)
: context.getTypeDeclaration(ownerFqn);
if (ownerType == null) {
ownerType = context.getTypeDeclaration(ownerFqn);
}
return simpleName;
return accessorResolver.resolve(
ownerFqn,
methodName,
new AccessorResolver.ReceiverContext(ownerType, null, contextCu, false, null),
ResolutionBudget.defaults());
}
private String tryNarrowGetterViaLocalVarChain(MethodInvocation getterCall, SimpleName instanceSn) {
@@ -1445,12 +1428,15 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
trackSetterCallsBetween(enclosing.getBody(), varName, getterCall, fieldValues, context);
MethodDeclaration getter = context.findMethodDeclaration(varTypeTd, getterName, true);
if (getter == null || getter.getBody() == null) {
return null;
List<String> resolved = accessorResolver.resolve(
context.getFqn(varTypeTd),
getterName,
new AccessorResolver.ReceiverContext(varTypeTd, cic, cu, false, fieldValues),
ResolutionBudget.defaults());
if (!resolved.isEmpty()) {
return resolved.get(0);
}
return constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, new HashSet<>());
return null;
}
private void trackSetterCallsBetween(
@@ -1778,7 +1764,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (!resolved.isEmpty()) {
return resolved;
}
return constantExtractor.resolveMethodReturnConstant(receiverType, getterName, 0, new HashSet<>(), contextCu);
}
}
} else if (exprNode instanceof MethodInvocation mi) {
@@ -2255,9 +2240,195 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return keyToType;
}
protected abstract Map<String, List<CallEdge>> buildCallGraph();
protected abstract String callGraphCacheKey();
protected abstract String resolveCalledMethod(MethodInvocation node);
@SuppressWarnings("unchecked")
protected Map<String, List<CallEdge>> buildCallGraph() {
Map<String, List<CallEdge>> cached =
(Map<String, List<CallEdge>>) context.getCache().get(callGraphCacheKey());
if (cached != null) {
this.graph = cached;
return cached;
}
graph = new HashMap<>();
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration td = findEnclosingType(md);
if (td != null) {
String callerFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
List<String> calledMethods = resolveCalledMethodsPolymorphic(node);
List<String> args = resolveArguments(node.arguments());
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils
.findConditionConstraint(node);
for (String calledMethod : calledMethods) {
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(calledMethod, args, receiver);
edge.setConstraint(resolveConstraint(node, calledMethod, constraint));
graph.computeIfAbsent(callerFqn, k -> new ArrayList<>()).add(edge);
}
for (Object argObj : node.arguments()) {
if (argObj instanceof ExpressionMethodReference emr) {
String typeName = emr.getExpression().toString();
if ("this".equals(typeName) || "super".equals(typeName)) {
TypeDeclaration refType = findEnclosingType(node);
if (refType != null) {
String refMethod = resolveMethodInType(refType, emr.getName().getIdentifier());
if (refMethod != null) {
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils
.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(refMethod, implicitArgs, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(callerFqn, k -> new ArrayList<>()).add(edge);
}
}
} else {
String fallbackTypeFqn = null;
ITypeBinding binding = emr.getExpression().resolveTypeBinding();
if (binding != null) {
fallbackTypeFqn = binding.getQualifiedName();
} else if (emr.getExpression() instanceof SimpleName sn) {
fallbackTypeFqn = resolveReceiverTypeFallback(sn);
}
if (fallbackTypeFqn != null) {
String calledMethod = fallbackTypeFqn + "." + emr.getName().getIdentifier();
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils
.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(calledMethod, implicitArgs, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(callerFqn, k -> new ArrayList<>()).add(edge);
List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) {
CallEdge implEdge = new CallEdge(
impl + "." + emr.getName().getIdentifier(), args, receiver);
implEdge.setConstraint(constraint);
graph.computeIfAbsent(callerFqn, k -> new ArrayList<>()).add(implEdge);
}
}
}
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SuperMethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration tdOuter = findEnclosingType(md);
if (tdOuter != null) {
String callerFqn = context.getFqn(tdOuter) + "." + md.getName().getIdentifier();
String methodName = node.getName().getIdentifier();
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
String calledMethod = null;
if (superTd != null) {
calledMethod = resolveMethodInType(superTd, methodName);
}
if (calledMethod == null) {
calledMethod = superFqn + "." + methodName;
}
List<String> args = resolveArguments(node.arguments());
String receiver = "super";
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils
.findConditionConstraint(node);
CallEdge edge = new CallEdge(calledMethod, args, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(callerFqn, k -> new ArrayList<>()).add(edge);
}
}
}
}
return super.visit(node);
}
});
}
context.getCache().put(callGraphCacheKey(), graph);
return graph;
}
protected String resolveMethodInvocationReturnType(MethodInvocation mi) {
Expression receiver = mi.getExpression();
String receiverType = null;
if (receiver == null) {
TypeDeclaration td = findEnclosingType(mi);
if (td != null) {
receiverType = context.getFqn(td);
}
} else if (receiver instanceof SimpleName sn) {
receiverType = resolveReceiverTypeFallback(sn);
} else if (receiver instanceof FieldAccess fa) {
receiverType = resolveReceiverTypeFallback(fa.getName());
} else if (receiver instanceof MethodInvocation innerMi) {
receiverType = resolveMethodInvocationReturnType(innerMi);
}
if (receiverType == null) {
ITypeBinding binding = receiver != null ? receiver.resolveTypeBinding() : null;
if (binding != null) {
receiverType = binding.getErasure().getQualifiedName();
}
}
if (receiverType != null) {
String rawType = receiverType;
if (rawType.contains("<")) {
rawType = rawType.substring(0, rawType.indexOf('<'));
}
if (rawType.equals("java.util.Map") || rawType.equals("Map")) {
String valType = resolveMapValueType(mi);
if (valType != null) {
return valType;
}
}
if (receiverType.contains("<")) {
receiverType = receiverType.substring(0, receiverType.indexOf('<'));
}
String methodName = mi.getName().getIdentifier();
TypeDeclaration td = context.getTypeDeclaration(receiverType);
if (td == null && receiverType.contains(".")) {
String simpleClass = receiverType.substring(receiverType.lastIndexOf('.') + 1);
td = context.getTypeDeclaration(simpleClass);
}
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getReturnType2() != null) {
return resolveTypeToFqn(md.getReturnType2(), mi);
}
}
}
IMethodBinding methodBinding = mi.resolveMethodBinding();
if (methodBinding != null && methodBinding.getReturnType() != null) {
return methodBinding.getReturnType().getErasure().getQualifiedName();
}
return null;
}
protected List<String> resolveCalledMethodsPolymorphic(MethodInvocation node) {
String baseCalled = resolveCalledMethod(node);
if (baseCalled == null) return Collections.emptyList();
@@ -2490,8 +2661,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
String ownerFqn = context.getFqn(td);
CompilationUnit cu = td.getRoot() instanceof CompilationUnit contextCu ? contextCu : null;
List<String> constants = constantExtractor.resolveMethodReturnConstant(
ownerFqn, methodName, 0, new HashSet<>(), cu);
List<String> constants = resolveAccessorConstants(ownerFqn, methodName, cu);
if (constants.isEmpty()) {
return null;
}

View File

@@ -59,7 +59,23 @@ public final class AnalysisResultFinalizer {
result.setEventTypeFqn(machineTypes.eventTypeFqn());
}
private static void applyCanonicalization(
/**
* Canonicalizes transitions, states, triggers, and matched transitions without validation.
* Safe to call before transition relinking when machine types are known.
*/
public static void applyCanonicalization(
AnalysisResult result,
StateMachineTypeResolver.MachineTypes machineTypes) {
if (result == null || machineTypes == null) {
return;
}
if (machineTypes.stateTypeFqn() == null && machineTypes.eventTypeFqn() == null) {
return;
}
applyCanonicalizationFields(result, machineTypes);
}
private static void applyCanonicalizationFields(
AnalysisResult result,
StateMachineTypeResolver.MachineTypes machineTypes) {
MachineEnumCanonicalizer.canonicalizeTransitions(result.getTransitions(), machineTypes);

View File

@@ -1,780 +0,0 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
@Slf4j
public class CallGraphBuilder {
private final CodebaseContext context;
private final ConstantResolver constantResolver;
private String currentMethodFqn;
private Map<String, List<CallEdge>> graph;
@lombok.Data
private static class CallEdge {
private final String targetMethod;
private final List<String> arguments;
}
public CallGraphBuilder(CodebaseContext context) {
this.context = context;
this.constantResolver = new ConstantResolver();
}
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
Map<String, List<CallEdge>> callGraph = buildCallGraph();
List<CallChain> chains = new ArrayList<>();
for (EntryPoint ep : entryPoints) {
String startMethod = ep.getClassName() + "." + ep.getMethodName();
boolean foundAny = false;
for (TriggerPoint tp : triggers) {
String targetMethod = tp.getClassName() + "." + tp.getMethodName();
List<String> path = findPath(startMethod, targetMethod, callGraph, new HashSet<>());
if (path != null) {
foundAny = true;
TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph);
String contextMachineId = extractContextMachineId(path, callGraph);
chains.add(CallChain.builder()
.entryPoint(ep)
.triggerPoint(resolvedTp)
.methodChain(path)
.contextMachineId(contextMachineId)
.build());
}
}
if (!foundAny && log.isDebugEnabled()) {
log.debug("Entry point {} ({}) did not reach any trigger points. Graph nodes available: {}", ep.getName(), startMethod, callGraph.keySet());
}
}
return chains;
}
private TriggerPoint resolveTriggerPointParameters(TriggerPoint tp, List<String> path, Map<String, List<CallEdge>> callGraph) {
if (path.size() < 2) return tp;
String event = tp.getEvent();
if (event == null || event.isEmpty() || event.contains("\"")) return tp;
String currentParamName = event;
String resolvedValue = event;
String methodSuffix = "";
// Extract method calls like .getType() so we can trace the base parameter
int dotIndex = currentParamName.indexOf('.');
if (dotIndex > 0 && dotIndex + 1 < currentParamName.length()) {
char nextChar = currentParamName.charAt(dotIndex + 1);
if (Character.isLowerCase(nextChar)) {
methodSuffix = currentParamName.substring(dotIndex);
currentParamName = currentParamName.substring(0, dotIndex);
}
}
// Walk backwards up the call chain
for (int i = path.size() - 1; i > 0; i--) {
String target = path.get(i);
String caller = path.get(i - 1);
// Find parameter index in target method
int paramIndex = getParameterIndex(target, currentParamName);
if (paramIndex < 0) {
// Not a parameter. Maybe it's a local variable initialized from a parameter or method call?
String tracedVar = traceLocalVariable(target, currentParamName);
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
// Extract method calls like .getType() from the traced variable
int dotIdx = tracedVar.indexOf('.');
if (dotIdx > 0 && dotIdx + 1 < tracedVar.length() && Character.isLowerCase(tracedVar.charAt(dotIdx + 1))) {
methodSuffix = tracedVar.substring(dotIdx) + methodSuffix;
tracedVar = tracedVar.substring(0, dotIdx);
}
currentParamName = tracedVar;
resolvedValue = tracedVar + methodSuffix;
paramIndex = getParameterIndex(target, currentParamName);
}
}
if (paramIndex < 0) {
break; // Parameter name changed or not found, stop tracing
}
// Find the edge from caller to target to get the argument passed
List<CallEdge> edges = callGraph.get(caller);
boolean found = false;
if (edges != null) {
for (CallEdge edge : edges) {
if (edge.getTargetMethod().equals(target) || isHeuristicMatch(edge.getTargetMethod(), target)) {
if (paramIndex < edge.getArguments().size()) {
String arg = edge.getArguments().get(paramIndex);
if (arg != null) {
// If the argument passed has a method call, extract it
int dotIdx = arg.indexOf('.');
if (dotIdx > 0 && dotIdx + 1 < arg.length() && Character.isLowerCase(arg.charAt(dotIdx + 1))) {
methodSuffix = arg.substring(dotIdx) + methodSuffix;
arg = arg.substring(0, dotIdx);
}
currentParamName = arg;
resolvedValue = arg + methodSuffix;
found = true;
break;
}
}
}
}
}
if (!found) break; // Could not map argument
}
// Final check on the entry method
String entryMethod = path.get(0);
int entryParamIndex = getParameterIndex(entryMethod, currentParamName);
if (entryParamIndex < 0) {
String tracedVar = traceLocalVariable(entryMethod, currentParamName);
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
int dotIdx = tracedVar.indexOf('.');
if (dotIdx > 0 && dotIdx + 1 < tracedVar.length() && Character.isLowerCase(tracedVar.charAt(dotIdx + 1))) {
methodSuffix = tracedVar.substring(dotIdx) + methodSuffix;
tracedVar = tracedVar.substring(0, dotIdx);
}
currentParamName = tracedVar;
resolvedValue = tracedVar + methodSuffix;
}
}
List<String> polymorphicEvents = new ArrayList<>();
if (!methodSuffix.isEmpty() && methodSuffix.matches("\\.[a-zA-Z0-9_]+\\(\\)")
&& currentParamName.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) {
String varName = currentParamName;
String methodName = methodSuffix.substring(1, methodSuffix.length() - 2);
// Resolve in the first method in the path where the variable might be declared
for (String methodFqn : path) {
String declaredType = getVariableDeclaredType(methodFqn, varName);
if (declaredType != null) {
List<String> typesToInspect = new ArrayList<>();
typesToInspect.add(declaredType);
typesToInspect.addAll(context.getImplementations(declaredType));
for (String type : typesToInspect) {
Set<String> visited = new HashSet<>();
// We must find the compilation unit to pass for simple names!
// Let's pass the first available CU for this class
TypeDeclaration baseTd = context.getTypeDeclaration(type);
CompilationUnit cuToUse = null;
if (baseTd != null && baseTd.getRoot() instanceof CompilationUnit) {
cuToUse = (CompilationUnit) baseTd.getRoot();
} else {
String entryClassName = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
TypeDeclaration entryTd = context.getTypeDeclaration(entryClassName);
if (entryTd != null && entryTd.getRoot() instanceof CompilationUnit) {
cuToUse = (CompilationUnit) entryTd.getRoot();
}
}
List<String> constants = resolveMethodReturnConstant(type, methodName, 0, visited, cuToUse);
for (String constant : constants) {
if (!polymorphicEvents.contains(constant)) {
polymorphicEvents.add(constant);
}
}
}
break;
}
}
}
if (!resolvedValue.equals(event) || !polymorphicEvents.isEmpty()) {
return TriggerPoint.builder()
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
.lineNumber(tp.getLineNumber())
.polymorphicEvents(polymorphicEvents)
.build();
}
return tp;
}
private int getParameterIndex(String methodFqn, String paramName) {
if (methodFqn == null || !methodFqn.contains(".")) return -1;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null) {
for (int i = 0; i < md.parameters().size(); i++) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) md.parameters().get(i);
if (svd.getName().getIdentifier().equals(paramName)) {
return i;
}
}
}
}
return -1;
}
private String getVariableDeclaredType(String methodFqn, String varName) {
if (methodFqn == null || !methodFqn.contains(".")) return null;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null) {
for (Object pObj : md.parameters()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) pObj;
if (svd.getName().getIdentifier().equals(varName)) {
return svd.getType().toString();
}
}
final String[] foundType = new String[1];
if (md.getBody() != null) {
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
foundType[0] = node.getType().toString();
}
}
return super.visit(node);
}
});
}
return foundType[0];
}
}
return null;
}
private List<String> resolveMethodReturnConstant(String className, String methodName, int depth, Set<String> visited, CompilationUnit contextCu) {
if (depth > 20) return Collections.emptyList();
String fqn = className + "." + methodName;
if (!visited.add(fqn)) return Collections.emptyList();
List<String> constants = new ArrayList<>();
TypeDeclaration td = contextCu != null ? context.getTypeDeclaration(className, contextCu) : context.getTypeDeclaration(className);
if (td == null) {
}
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
Expression retExpr = node.getExpression();
if (retExpr != null) {
boolean handled = false;
if (retExpr instanceof MethodInvocation mi) {
// Follow delegation first
String called = resolveCalledMethod(mi);
if (called != null && called.contains(".")) {
if (visited.contains(called)) {
handled = true;
} else {
String cName = called.substring(0, called.lastIndexOf('.'));
String mName = called.substring(called.lastIndexOf('.') + 1);
TypeDeclaration targetTd = contextCu != null ? context.getTypeDeclaration(cName, contextCu) : context.getTypeDeclaration(cName);
if (targetTd == null) targetTd = context.getTypeDeclaration(cName);
if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) {
List<String> delegationResult = resolveMethodReturnConstant(cName, mName, depth + 1, visited, contextCu);
constants.addAll(delegationResult);
handled = true;
}
}
}
}
if (!handled) {
String val = constantResolver.resolve(retExpr, context);
if (val != null) {
if (val.startsWith("ENUM_SET:")) {
for (String eVal : val.substring(9).split(",")) {
constants.add(eVal.substring(eVal.lastIndexOf('.') + 1));
}
} else {
constants.add(val);
}
} else if (retExpr instanceof QualifiedName qn) {
constants.add(qn.toString());
} else if (retExpr instanceof SimpleName sn) {
constants.add(sn.toString());
}
}
}
return super.visit(node);
}
});
}
}
visited.remove(fqn);
return constants;
}
private String traceLocalVariable(String methodFqn, String varName) {
if (methodFqn == null || !methodFqn.contains(".")) return null;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
final Expression[] initializer = new Expression[1];
md.getBody().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) {
Expression expr = traceVariable(initializer[0]);
if (expr instanceof MethodInvocation mi) {
// Unwrapper logic: If wrapper method is called, extract its arguments recursively
Expression innerMost = unwrapMethodInvocation(mi, 0);
if (innerMost instanceof MethodInvocation innerMi) {
if (innerMi.getExpression() instanceof SimpleName sn) {
return sn.getIdentifier() + "." + innerMi.getName().getIdentifier() + "()";
}
return innerMi.getName().getIdentifier() + "()";
}
if (innerMost instanceof SimpleName sn) {
return sn.getIdentifier();
}
return innerMost.toString();
}
if (expr instanceof SimpleName sn) {
return sn.getIdentifier();
}
return expr.toString();
}
}
}
return null;
}
private Expression unwrapMethodInvocation(MethodInvocation mi, int depth) {
if (depth > 5) return mi;
if (!mi.arguments().isEmpty()) {
Expression arg = (Expression) mi.arguments().get(0);
if (arg instanceof MethodInvocation innerMi) {
return unwrapMethodInvocation(innerMi, depth + 1);
}
return arg;
}
return mi;
}
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 MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
}
private Map<String, List<CallEdge>> buildCallGraph() {
graph = new HashMap<>();
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration td = findEnclosingType(md);
if (td != null) {
String currentMethodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
List<String> calledMethods = resolveCalledMethodsPolymorphic(node);
List<String> args = resolveArguments(node.arguments());
for (String calledMethod : calledMethods) {
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args));
}
for (Object argObj : node.arguments()) {
if (argObj instanceof ExpressionMethodReference emr) {
String typeName = emr.getExpression().toString();
if ("this".equals(typeName) || "super".equals(typeName)) {
TypeDeclaration td2 = findEnclosingType(node);
if (td2 != null) {
String refMethod = resolveMethodInType(td2, emr.getName().getIdentifier());
if (refMethod != null) {
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(refMethod, args));
}
}
} else {
String fallbackTypeFqn = null;
ITypeBinding binding = emr.getExpression().resolveTypeBinding();
if (binding != null) {
fallbackTypeFqn = binding.getQualifiedName();
} else if (emr.getExpression() instanceof SimpleName sn) {
fallbackTypeFqn = resolveReceiverTypeFallback(sn);
}
if (fallbackTypeFqn != null) {
String calledMethod = fallbackTypeFqn + "." + emr.getName().getIdentifier();
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args));
List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) {
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(impl + "." + emr.getName().getIdentifier(), args));
}
}
}
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SuperMethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration tdOuter = findEnclosingType(md);
if (tdOuter != null) {
String currentMethodFqn = context.getFqn(tdOuter) + "." + md.getName().getIdentifier();
String methodName = node.getName().getIdentifier();
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
String calledMethod = null;
if (superTd != null) {
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));
}
}
}
}
return super.visit(node);
}
});
}
return graph;
}
private List<String> resolveArguments(List<?> astArguments) {
List<String> args = new ArrayList<>();
for (Object argObj : astArguments) {
Expression expr = (Expression) argObj;
// Extract from lambda
if (expr instanceof LambdaExpression le) {
ASTNode body = le.getBody();
if (body instanceof Expression bodyExpr) {
expr = bodyExpr;
} else if (body instanceof Block block) {
for (Object stmtObj : block.statements()) {
if (stmtObj instanceof ReturnStatement rs && rs.getExpression() != null) {
expr = rs.getExpression();
break;
}
}
}
}
if (expr instanceof ExpressionMethodReference emr) {
TypeDeclaration td = findEnclosingType(expr);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, emr.getName().getIdentifier(), true);
if (md != null && md.getBody() != null) {
for (Object stmtObj : md.getBody().statements()) {
if (stmtObj instanceof ReturnStatement rs && rs.getExpression() != null) {
expr = rs.getExpression();
break;
}
}
}
}
}
// Extract from constructor args (e.g., new CustomMessage(OrderEvent.PROCESS, ...))
Expression tracedExpr = traceVariable(expr);
if (tracedExpr instanceof QualifiedName || tracedExpr instanceof ClassInstanceCreation || tracedExpr instanceof StringLiteral || tracedExpr instanceof NumberLiteral) {
expr = tracedExpr; // Only accept trace if it resolves to a pure constant/primitive/constructor
}
if (expr instanceof ClassInstanceCreation cic && !cic.arguments().isEmpty()) {
Expression firstArg = (Expression) cic.arguments().get(0);
String resolved = constantResolver.resolve(firstArg, context);
if (resolved != null) {
expr = firstArg; // Only unwrap if it's actually a constant
}
}
String val;
if (expr instanceof MethodInvocation mi && mi.getExpression() instanceof SimpleName
&& mi.arguments().isEmpty()) {
// Keep local getter chains (e.g. event.getType()) for downstream parameter tracing.
val = expr.toString();
} else {
val = constantResolver.resolve(expr, context);
if (val == null) {
val = expr.toString(); // Fallback to raw string (handles Enums, 'new Class()', etc)
}
}
args.add(val);
}
return args;
}
private List<String> resolveCalledMethodsPolymorphic(MethodInvocation node) {
String baseCalled = resolveCalledMethod(node);
if (baseCalled == null) return Collections.emptyList();
List<String> allResolved = new ArrayList<>();
allResolved.add(baseCalled);
if (baseCalled.contains(".")) {
String className = baseCalled.substring(0, baseCalled.lastIndexOf('.'));
String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1);
List<String> impls = context.getImplementations(className);
for (String impl : impls) {
allResolved.add(impl + "." + methodName);
}
}
return allResolved;
}
private String resolveCalledMethod(MethodInvocation node) {
Expression receiver = node.getExpression();
String methodName = node.getName().getIdentifier();
if (receiver == null) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return resolveMethodInType(td, methodName);
}
return null;
}
ITypeBinding binding = receiver.resolveTypeBinding();
if (binding != null) {
return binding.getQualifiedName() + "." + methodName;
}
if (receiver instanceof ThisExpression) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return context.getFqn(td) + "." + methodName;
}
}
if (receiver instanceof SimpleName sn) {
String fallbackTypeFqn = resolveReceiverTypeFallback(sn);
if (fallbackTypeFqn != null) {
return fallbackTypeFqn + "." + methodName;
}
String receiverName = sn.getIdentifier();
return receiverName + "." + methodName;
}
return null;
}
private String resolveReceiverTypeFallback(SimpleName receiverNameNode) {
String varName = receiverNameNode.getIdentifier();
// 1. Check local variables in enclosing method
MethodDeclaration enclosingMethod = findEnclosingMethod(receiverNameNode);
if (enclosingMethod != null) {
// Check parameters
for (Object paramObj : enclosingMethod.parameters()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) paramObj;
if (svd.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(svd.getType(), receiverNameNode);
}
}
// Check method body (local variables)
if (enclosingMethod.getBody() != null) {
Type[] foundType = new Type[1];
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
foundType[0] = node.getType();
}
}
return super.visit(node);
}
});
if (foundType[0] != null) {
return resolveTypeToFqn(foundType[0], receiverNameNode);
}
}
}
// 2. Check fields in enclosing class
TypeDeclaration enclosingType = findEnclosingType(receiverNameNode);
if (enclosingType != null) {
for (FieldDeclaration field : enclosingType.getFields()) {
for (Object fragObj : field.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(field.getType(), receiverNameNode);
}
}
}
}
return null;
}
private String resolveTypeToFqn(Type type, ASTNode contextNode) {
if (type == null) return null;
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), contextNode);
} else {
simpleName = type.toString();
}
CompilationUnit cu = (CompilationUnit) contextNode.getRoot();
TypeDeclaration td = context.getTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
// Fallback to import matching if CodebaseContext doesn't know it (e.g., external library)
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
return simpleName;
}
private String resolveMethodInType(TypeDeclaration td, String methodName) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null) {
TypeDeclaration declaringTd = findEnclosingType(md);
return context.getFqn(declaringTd) + "." + methodName;
}
return null;
}
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; // 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;
}
}
}
if (log.isDebugEnabled()) {
log.debug("Path search dead-end at {} when looking for {}", start, target);
}
visited.remove(start);
return null;
}
private boolean isHeuristicMatch(String neighbor, String target) {
if (target.endsWith("." + neighbor)) return true;
String simpleTarget = target.contains(".") ? target.substring(target.lastIndexOf('.') + 1) : target;
String simpleNeighbor = neighbor.contains(".") ? neighbor.substring(neighbor.lastIndexOf('.') + 1) : neighbor;
return simpleNeighbor.equals(simpleTarget);
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
private Expression traceVariable(Expression expr) {
if (expr instanceof SimpleName sn) {
String varName = sn.getIdentifier();
MethodDeclaration enclosingMethod = findEnclosingMethod(expr);
if (enclosingMethod != null) {
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 traceVariable(initializer[0]);
}
}
}
return expr;
}
}

View File

@@ -3,6 +3,7 @@ package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.index.AccessorFieldResolver;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
import click.kamil.springstatemachineexporter.analysis.pipeline.GetterBodyConstantScanner;
import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
@@ -531,36 +532,7 @@ public class ConstantExtractor {
if (className == null || className.isBlank()) {
return Optional.empty();
}
Optional<AccessorSummary> direct = context.getAccessorIndex().lookup(className, methodName);
if (direct.isPresent()) {
return direct;
}
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
if (typeDeclaration != null) {
direct = context.getAccessorIndex().lookup(context.getFqn(typeDeclaration), methodName);
if (direct.isPresent()) {
return direct;
}
if (!typeDeclaration.isInterface() && !Modifier.isAbstract(typeDeclaration.getModifiers())) {
return Optional.empty();
}
}
List<String> implementations = context.getImplementations(className);
if (implementations == null || implementations.isEmpty()) {
return Optional.empty();
}
Optional<AccessorSummary> precomputed = context.getPolymorphicAccessorIndex()
.representativeImplAccessor(className, methodName);
if (precomputed.isPresent()) {
return precomputed;
}
for (String implementation : implementations) {
Optional<AccessorSummary> implAccessor = context.getAccessorIndex().lookup(implementation, methodName);
if (implAccessor.isPresent()) {
return implAccessor;
}
}
return Optional.empty();
return context.getAccessorIndex().lookup(className, methodName);
}
private List<String> extractConstantGetterReturn(
@@ -568,30 +540,8 @@ public class ConstantExtractor {
String methodName,
CompilationUnit contextCu,
Set<String> visited) {
TypeDeclaration typeDeclaration = contextCu != null
? context.getTypeDeclaration(ownerFqn, contextCu)
: null;
if (typeDeclaration == null) {
typeDeclaration = context.getTypeDeclaration(ownerFqn);
}
if (typeDeclaration == null) {
return List.of();
}
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
return List.of();
}
List<String> constants = new ArrayList<>();
methodDeclaration.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() != null) {
extractConstantsFromExpression(node.getExpression(), constants);
}
return false;
}
});
return constants;
return GetterBodyConstantScanner.extractConstantGetterReturn(
context, this, constantResolver, ownerFqn, methodName, contextCu, visited);
}
@@ -699,10 +649,6 @@ public class ConstantExtractor {
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
}
}

View File

@@ -821,18 +821,10 @@ public class ConstructorAnalyzer {
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
}
}

View File

@@ -5,7 +5,6 @@ import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
@@ -15,11 +14,18 @@ import java.util.List;
import java.util.Set;
@Slf4j
@RequiredArgsConstructor
public class GenericEventDetector {
private final CodebaseContext context;
private final ConstantResolver constantResolver;
private final List<LibraryHint> hints;
private final TypeResolver typeResolver;
public GenericEventDetector(CodebaseContext context, ConstantResolver constantResolver, List<LibraryHint> hints) {
this.context = context;
this.constantResolver = constantResolver;
this.hints = hints;
this.typeResolver = new TypeResolver(context);
}
/** Parameter names that route to a machine, not SM state enum constants. */
private static final Set<String> ROUTING_PARAMETER_NAMES = Set.of(
@@ -77,7 +83,7 @@ public class GenericEventDetector {
eventValue = receiver != null ? receiver.toString() : null;
}
MethodDeclaration method = findEnclosingMethod(node);
MethodDeclaration method = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
AbstractTypeDeclaration type = findEnclosingAbstractType(node);
if (type == null) return;
@@ -149,8 +155,8 @@ public class GenericEventDetector {
List<?> typeArgs = pt.typeArguments();
if (typeArgs.size() >= 2) {
CompilationUnit compilationUnit = (CompilationUnit) node.getRoot();
String stateType = resolveTypeToFqn((Type) typeArgs.get(0), compilationUnit);
String eventType = resolveTypeToFqn((Type) typeArgs.get(1), compilationUnit);
String stateType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(0), compilationUnit);
String eventType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(1), compilationUnit);
return new String[]{stateType, eventType};
}
}
@@ -162,7 +168,9 @@ public class GenericEventDetector {
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, null);
if (builtTriggers != null) {
for (TriggerPoint trigger : builtTriggers) {
log.debug("Successfully built trigger point: {}", trigger.getEvent());
if (log.isDebugEnabled()) {
log.debug("Successfully built trigger point: {}", trigger.getEvent());
}
triggers.add(trigger);
}
}
@@ -190,7 +198,9 @@ public class GenericEventDetector {
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, eventToUse);
if (builtTriggers != null) {
for (TriggerPoint trigger : builtTriggers) {
log.debug("Successfully built synthetic trigger point from hint: {}", trigger.getEvent());
if (log.isDebugEnabled()) {
log.debug("Successfully built synthetic trigger point from hint: {}", trigger.getEvent());
}
triggers.add(trigger);
}
}
@@ -245,7 +255,7 @@ public class GenericEventDetector {
}
}
MethodDeclaration method = findEnclosingMethod(node);
MethodDeclaration method = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
AbstractTypeDeclaration type = findEnclosingAbstractType(node);
if (type == null) return Collections.emptyList();
@@ -453,7 +463,7 @@ public class GenericEventDetector {
if (expr instanceof SimpleName sn) {
String varName = sn.getIdentifier();
MethodDeclaration enclosingMethod = findEnclosingMethod(expr);
MethodDeclaration enclosingMethod = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(expr);
if (enclosingMethod != null) {
// Find variable declaration
final Expression[] initializer = new Expression[1];
@@ -525,7 +535,7 @@ public class GenericEventDetector {
return payloadExpr.toString();
} else if (current.arguments().isEmpty() && current.getExpression() instanceof SimpleName sn) {
// If the event is obtained by calling a method on a provider/supplier parameter,
// return the provider's name so CallGraphBuilder can trace the lambda argument
// return the provider's name so the call-graph engine can trace the lambda argument
String traced = extractEventFromMessageBuilder(sn);
return traced != null ? traced : sn.getIdentifier();
}
@@ -540,19 +550,6 @@ public class GenericEventDetector {
return null;
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
}
private TypeDeclaration findEnclosingType(ASTNode node) {
AbstractTypeDeclaration type = findEnclosingAbstractType(node);
return type instanceof TypeDeclaration td ? td : null;
}
private AbstractTypeDeclaration findEnclosingAbstractType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof AbstractTypeDeclaration)) {
@@ -630,8 +627,8 @@ public class GenericEventDetector {
List<?> typeArgs = pt.typeArguments();
if (typeArgs.size() >= 2) {
CompilationUnit cu = (CompilationUnit) node.getRoot();
String stateType = resolveTypeToFqn((Type) typeArgs.get(0), cu);
String eventType = resolveTypeToFqn((Type) typeArgs.get(1), cu);
String stateType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(0), cu);
String eventType = typeResolver.resolveTypeToFqn((Type) typeArgs.get(1), cu);
if (stateType != null && stateType.length() == 1) {
stateType = resolveGenericTypeVariable(stateType, node, cu);
@@ -661,7 +658,7 @@ public class GenericEventDetector {
Type superclassType = td.getSuperclassType();
if (superclassType instanceof ParameterizedType pt) {
Type baseType = pt.getType();
String baseName = resolveTypeToFqn(baseType, (CompilationUnit) implNode.getRoot());
String baseName = typeResolver.resolveTypeToFqn(baseType, (CompilationUnit) implNode.getRoot());
if (className.equals(baseName) || className.endsWith("." + baseName)) {
int typeIndex = -1;
List<?> typeParams = enclosingClass instanceof TypeDeclaration typeDecl
@@ -678,7 +675,7 @@ public class GenericEventDetector {
}
if (typeIndex >= 0 && typeIndex < pt.typeArguments().size()) {
Type concreteType = (Type) pt.typeArguments().get(typeIndex);
return resolveTypeToFqn(concreteType, (CompilationUnit) implNode.getRoot());
return typeResolver.resolveTypeToFqn(concreteType, (CompilationUnit) implNode.getRoot());
}
}
}
@@ -710,7 +707,7 @@ public class GenericEventDetector {
if (!(receiver instanceof SimpleName sn)) return null;
String varName = sn.getIdentifier();
MethodDeclaration enclosingMethod = findEnclosingMethod(node);
MethodDeclaration enclosingMethod = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
if (enclosingMethod != null) {
// Check params
for (Object paramObj : enclosingMethod.parameters()) {
@@ -752,41 +749,6 @@ public class GenericEventDetector {
return null;
}
private String resolveTypeToFqn(Type type, CompilationUnit cu) {
if (type == null) return null;
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), cu);
} else {
simpleName = type.toString();
}
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
// Fallback to import matching
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
// Check package name
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
if (!packageName.isEmpty()) {
AbstractTypeDeclaration localTd = context.getAbstractTypeDeclaration(packageName + "." + simpleName);
if (localTd != null) return context.getFqn(localTd);
}
return simpleName;
}
private static class AssignmentDag {
final java.util.Map<String, List<Expression>> assignments = new java.util.HashMap<>();
final java.util.Set<SingleVariableDeclaration> parameters = new java.util.HashSet<>();

View File

@@ -1,157 +1,22 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
@Slf4j
public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
protected String currentMethodFqn;
protected Map<String, List<CallEdge>> graph;
public HeuristicCallGraphEngine(CodebaseContext context) {
super(context);
}
@SuppressWarnings("unchecked")
protected Map<String, List<CallEdge>> buildCallGraph() {
Map<String, List<CallEdge>> cached = (Map<String, List<CallEdge>>) context.getCache().get("heuristicCallGraph");
if (cached != null) {
this.graph = cached;
return cached;
}
graph = new HashMap<>();
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration td = findEnclosingType(md);
if (td != null) {
String currentMethodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
List<String> calledMethods = resolveCalledMethodsPolymorphic(node);
List<String> args = resolveArguments(node.arguments());
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
for (String calledMethod : calledMethods) {
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(calledMethod, args, receiver);
edge.setConstraint(resolveConstraint(node, calledMethod, constraint));
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
for (Object argObj : node.arguments()) {
if (argObj instanceof ExpressionMethodReference emr) {
String typeName = emr.getExpression().toString();
if ("this".equals(typeName) || "super".equals(typeName)) {
TypeDeclaration td2 = findEnclosingType(node);
if (td2 != null) {
String refMethod = resolveMethodInType(td2, emr.getName().getIdentifier());
if (refMethod != null) {
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(refMethod, implicitArgs, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
}
} else {
String fallbackTypeFqn = null;
ITypeBinding binding = emr.getExpression().resolveTypeBinding();
if (binding != null) {
fallbackTypeFqn = binding.getQualifiedName();
} else if (emr.getExpression() instanceof SimpleName sn) {
fallbackTypeFqn = resolveReceiverTypeFallback(sn);
}
if (fallbackTypeFqn != null) {
String calledMethod = fallbackTypeFqn + "." + emr.getName().getIdentifier();
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(calledMethod, implicitArgs, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) {
CallEdge implEdge = new CallEdge(impl + "." + emr.getName().getIdentifier(), args, receiver);
implEdge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(implEdge);
}
}
}
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SuperMethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration tdOuter = findEnclosingType(md);
if (tdOuter != null) {
String currentMethodFqn = context.getFqn(tdOuter) + "." + md.getName().getIdentifier();
String methodName = node.getName().getIdentifier();
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
String calledMethod = null;
if (superTd != null) {
calledMethod = resolveMethodInType(superTd, methodName);
}
if (calledMethod == null) {
calledMethod = superFqn + "." + methodName;
}
List<String> args = resolveArguments(node.arguments());
String receiver = "super";
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
CallEdge edge = new CallEdge(calledMethod, args, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
}
}
}
return super.visit(node);
}
});
}
context.getCache().put("heuristicCallGraph", graph);
return graph;
}
private String findDefiningClass(String className, String methodName) {
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) return null;
String resolvedMethodFqn = resolveMethodInTypeHierarchy(td, methodName);
if (resolvedMethodFqn != null) {
return resolvedMethodFqn.substring(0, resolvedMethodFqn.lastIndexOf('.'));
}
return null;
@Override
protected String callGraphCacheKey() {
return "heuristicCallGraph";
}
@Override
protected String resolveCalledMethod(MethodInvocation node) {
Expression receiver = node.getExpression();
String methodName = node.getName().getIdentifier();
@@ -188,8 +53,7 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
if (fallbackTypeFqn != null) {
return fallbackTypeFqn + "." + methodName;
}
String receiverName = sn.getIdentifier();
return receiverName + "." + methodName;
return sn.getIdentifier() + "." + methodName;
}
if (receiver instanceof QualifiedName qn) {
@@ -198,66 +62,9 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
if (td != null) {
return fqn + "." + methodName;
}
return fqn + "." + methodName;
return fqn + "." + methodName;
}
return null;
}
protected String resolveMethodInvocationReturnType(MethodInvocation mi) {
Expression receiver = mi.getExpression();
String receiverType = null;
if (receiver == null) {
TypeDeclaration td = findEnclosingType(mi);
if (td != null) {
receiverType = context.getFqn(td);
}
} else if (receiver instanceof SimpleName sn) {
receiverType = resolveReceiverTypeFallback(sn);
} else if (receiver instanceof FieldAccess fa) {
receiverType = resolveReceiverTypeFallback(fa.getName());
} else if (receiver instanceof MethodInvocation innerMi) {
receiverType = resolveMethodInvocationReturnType(innerMi);
}
if (receiverType == null) {
ITypeBinding binding = receiver != null ? receiver.resolveTypeBinding() : null;
if (binding != null) {
receiverType = binding.getErasure().getQualifiedName();
}
}
if (receiverType != null) {
String rawType = receiverType;
if (rawType.contains("<")) {
rawType = rawType.substring(0, rawType.indexOf('<'));
}
if (rawType.equals("java.util.Map") || rawType.equals("Map")) {
String valType = resolveMapValueType(mi);
if (valType != null) return valType;
}
if (receiverType.contains("<")) {
receiverType = receiverType.substring(0, receiverType.indexOf('<'));
}
String methodName = mi.getName().getIdentifier();
TypeDeclaration td = context.getTypeDeclaration(receiverType);
if (td == null && receiverType.contains(".")) {
String simpleClass = receiverType.substring(receiverType.lastIndexOf('.') + 1);
td = context.getTypeDeclaration(simpleClass);
}
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getReturnType2() != null) {
return resolveTypeToFqn(md.getReturnType2(), mi);
}
}
}
IMethodBinding methodBinding = mi.resolveMethodBinding();
if (methodBinding != null && methodBinding.getReturnType() != null) {
return methodBinding.getReturnType().getErasure().getQualifiedName();
}
return null;
}
}
}

View File

@@ -1,171 +1,42 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
@Slf4j
public class JdtCallGraphEngine extends AbstractCallGraphEngine {
private final InjectionPointAnalyzer injectionAnalyzer;
private String currentMethodFqn;
private Map<String, List<CallEdge>> graph;
public JdtCallGraphEngine(CodebaseContext context, InjectionPointAnalyzer injectionAnalyzer) {
super(context);
this.injectionAnalyzer = injectionAnalyzer;
}
@SuppressWarnings("unchecked")
protected Map<String, List<CallEdge>> buildCallGraph() {
Map<String, List<CallEdge>> cached = (Map<String, List<CallEdge>>) context.getCache().get("jdtCallGraph");
if (cached != null) {
this.graph = cached;
return cached;
}
graph = new HashMap<>();
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration td = findEnclosingType(md);
if (td != null) {
String currentMethodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
List<String> calledMethods = resolveCalledMethodsPolymorphic(node);
List<String> args = resolveArguments(node.arguments());
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
for (String calledMethod : calledMethods) {
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(calledMethod, args, receiver);
edge.setConstraint(resolveConstraint(node, calledMethod, constraint));
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
for (Object argObj : node.arguments()) {
if (argObj instanceof ExpressionMethodReference emr) {
String typeName = emr.getExpression().toString();
if ("this".equals(typeName) || "super".equals(typeName)) {
TypeDeclaration td2 = findEnclosingType(node);
if (td2 != null) {
String refMethod = resolveMethodInType(td2, emr.getName().getIdentifier());
if (refMethod != null) {
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(refMethod, implicitArgs, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
}
} else {
String fallbackTypeFqn = null;
ITypeBinding binding = emr.getExpression().resolveTypeBinding();
if (binding != null) {
fallbackTypeFqn = binding.getQualifiedName();
} else if (emr.getExpression() instanceof SimpleName sn) {
fallbackTypeFqn = resolveReceiverTypeFallback(sn);
}
if (fallbackTypeFqn != null) {
String calledMethod = fallbackTypeFqn + "." + emr.getName().getIdentifier();
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(calledMethod, implicitArgs, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) {
CallEdge implEdge = new CallEdge(impl + "." + emr.getName().getIdentifier(), args, receiver);
implEdge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(implEdge);
}
}
}
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SuperMethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration tdOuter = findEnclosingType(md);
if (tdOuter != null) {
String currentMethodFqn = context.getFqn(tdOuter) + "." + md.getName().getIdentifier();
String methodName = node.getName().getIdentifier();
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
String calledMethod = null;
if (superTd != null) {
calledMethod = resolveMethodInType(superTd, methodName);
}
if (calledMethod == null) {
calledMethod = superFqn + "." + methodName;
}
List<String> args = resolveArguments(node.arguments());
String receiver = "super";
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
CallEdge edge = new CallEdge(calledMethod, args, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
}
}
}
return super.visit(node);
}
});
}
context.getCache().put("jdtCallGraph", graph);
return graph;
@Override
protected String callGraphCacheKey() {
return "jdtCallGraph";
}
@Override
protected String resolveArgument(org.eclipse.jdt.core.dom.Expression expr) {
protected String resolveArgument(Expression expr) {
if (expr == null) {
return "null";
}
// Extract from constructor args (e.g., new CustomMessage(OrderEvent.PROCESS, ...))
org.eclipse.jdt.core.dom.Expression unwrappedBuilder = unwrapMessageBuilder(expr);
Expression unwrappedBuilder = unwrapMessageBuilder(expr);
if (unwrappedBuilder != expr) {
expr = unwrappedBuilder;
}
// Trace variable to resolve local variable references
org.eclipse.jdt.core.dom.Expression tracedExpr = traceVariable(expr);
if (tracedExpr instanceof org.eclipse.jdt.core.dom.QualifiedName
|| tracedExpr instanceof org.eclipse.jdt.core.dom.ClassInstanceCreation
|| tracedExpr instanceof org.eclipse.jdt.core.dom.StringLiteral
|| tracedExpr instanceof org.eclipse.jdt.core.dom.NumberLiteral) {
expr = tracedExpr; // Only accept trace if it resolves to a pure constant/primitive/constructor
Expression tracedExpr = traceVariable(expr);
if (tracedExpr instanceof QualifiedName
|| tracedExpr instanceof ClassInstanceCreation
|| tracedExpr instanceof StringLiteral
|| tracedExpr instanceof NumberLiteral) {
expr = tracedExpr;
}
// Delegate to base class for lambda, method reference, and constructor unwrapping
String result = super.resolveArgument(expr);
if (result != null) {
result = result.replaceAll("->\\s*yield\\s+", "-> ");
@@ -173,6 +44,7 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
return result;
}
@Override
protected String resolveCalledMethod(MethodInvocation node) {
Expression receiver = node.getExpression();
String methodName = node.getName().getIdentifier();
@@ -209,15 +81,11 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
log.debug(" -> RESOLVED CONCRETE FQN: {}", concreteFqn);
}
return concreteFqn + "." + methodName;
} else {
if (log.isDebugEnabled()) {
log.debug(" -> RESOLVE FAILED, RETURNED NULL");
}
}
} else {
if (log.isDebugEnabled()) {
log.debug("CALLGRAPH NAME BINDING IS NOT VARIABLE: {} calling {}", nameBinding, methodName);
} else if (log.isDebugEnabled()) {
log.debug(" -> RESOLVE FAILED, RETURNED NULL");
}
} else if (log.isDebugEnabled()) {
log.debug("CALLGRAPH NAME BINDING IS NOT VARIABLE: {} calling {}", nameBinding, methodName);
}
}
@@ -231,7 +99,7 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
typeName = binding.getErasure().getName();
}
}
} catch (Exception e) {
} catch (Exception ignored) {
// Ignore JDT internal exceptions
}
@@ -246,7 +114,7 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
}
if (typeName != null && !typeName.isEmpty()) {
org.eclipse.jdt.core.dom.TypeDeclaration resolvedTd = context.getTypeDeclaration(typeName);
TypeDeclaration resolvedTd = context.getTypeDeclaration(typeName);
if (resolvedTd != null && context.findMethodDeclaration(resolvedTd, methodName, true) != null) {
return typeName + "." + methodName;
}
@@ -265,8 +133,7 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
if (fallbackTypeFqn != null) {
return fallbackTypeFqn + "." + methodName;
}
String receiverName = sn.getIdentifier();
return receiverName + "." + methodName;
return sn.getIdentifier() + "." + methodName;
}
return null;
@@ -290,55 +157,4 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
}
return expr;
}
protected String resolveMethodInvocationReturnType(MethodInvocation mi) {
Expression receiver = mi.getExpression();
String receiverType = null;
if (receiver == null) {
TypeDeclaration td = findEnclosingType(mi);
if (td != null) {
receiverType = context.getFqn(td);
}
} else if (receiver instanceof SimpleName sn) {
receiverType = resolveReceiverTypeFallback(sn);
} else if (receiver instanceof FieldAccess fa) {
receiverType = resolveReceiverTypeFallback(fa.getName());
} else if (receiver instanceof MethodInvocation innerMi) {
receiverType = resolveMethodInvocationReturnType(innerMi);
}
if (receiverType == null) {
ITypeBinding binding = receiver != null ? receiver.resolveTypeBinding() : null;
if (binding != null) {
receiverType = binding.getErasure().getQualifiedName();
}
}
if (receiverType != null) {
String rawType = receiverType;
if (rawType.contains("<")) {
rawType = rawType.substring(0, rawType.indexOf('<'));
}
if (rawType.equals("java.util.Map") || rawType.equals("Map")) {
String valType = resolveMapValueType(mi);
if (valType != null) return valType;
}
if (receiverType.contains("<")) {
receiverType = receiverType.substring(0, receiverType.indexOf('<'));
}
String methodName = mi.getName().getIdentifier();
TypeDeclaration td = context.getTypeDeclaration(receiverType);
if (td == null && receiverType.contains(".")) {
String simpleClass = receiverType.substring(receiverType.lastIndexOf('.') + 1);
td = context.getTypeDeclaration(simpleClass);
}
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getReturnType2() != null) {
return resolveTypeToFqn(md.getReturnType2(), mi);
}
}
}
return null;
}
}

View File

@@ -18,15 +18,19 @@ public final class JsonExportContextFactory {
public static Path findProjectRoot(Path start) {
Path current = start != null ? start.toAbsolutePath().normalize() : null;
Path settingsRoot = null;
Path buildRoot = null;
while (current != null) {
if (Files.exists(current.resolve("settings.gradle"))
|| Files.exists(current.resolve("pom.xml"))
|| Files.exists(current.resolve("build.gradle"))) {
return current;
if (settingsRoot == null && Files.exists(current.resolve("settings.gradle"))) {
settingsRoot = current;
}
if (buildRoot == null && (Files.exists(current.resolve("pom.xml"))
|| Files.exists(current.resolve("build.gradle")))) {
buildRoot = current;
}
current = current.getParent();
}
return null;
return settingsRoot != null ? settingsRoot : buildRoot;
}
public static CodebaseContext scanProjectRoot(Path projectRoot, List<String> activeProfiles) throws IOException {

View File

@@ -31,8 +31,8 @@ public class LifecycleDetector {
}
private TriggerPoint buildLifecyclePoint(MethodInvocation node, CompilationUnit cu, String type) {
MethodDeclaration method = findEnclosingMethod(node);
TypeDeclaration typeDecl = findEnclosingType(node);
MethodDeclaration method = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
TypeDeclaration typeDecl = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
if (typeDecl == null) return null;
@@ -43,20 +43,4 @@ public class LifecycleDetector {
.lineNumber(cu.getLineNumber(node.getStartPosition()))
.build();
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
}

View File

@@ -6,10 +6,7 @@ import lombok.RequiredArgsConstructor;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.MemberValuePair;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.NormalAnnotation;
import org.eclipse.jdt.core.dom.SingleMemberAnnotation;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
@@ -36,13 +33,13 @@ public class SpringComponentDetector {
String typeName = annotation.getTypeName().getFullyQualifiedName();
if (typeName.endsWith("Scheduled")) {
Map<String, String> meta = new HashMap<>();
String cron = extractAnnotationValue(annotation, "cron");
String cron = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "cron");
if (!cron.isEmpty())
meta.put("cron", cron);
String fixedRate = extractAnnotationValue(annotation, "fixedRate");
String fixedRate = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "fixedRate");
if (!fixedRate.isEmpty())
meta.put("fixedRate", fixedRate);
String fixedDelay = extractAnnotationValue(annotation, "fixedDelay");
String fixedDelay = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "fixedDelay");
if (!fixedDelay.isEmpty())
meta.put("fixedDelay", fixedDelay);
@@ -56,10 +53,10 @@ public class SpringComponentDetector {
.build());
} else if (typeName.endsWith("EventListener")) {
Map<String, String> meta = new HashMap<>();
String classes = extractAnnotationValue(annotation, "classes");
String classes = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "classes");
if (!classes.isEmpty())
meta.put("classes", classes);
String condition = extractAnnotationValue(annotation, "condition");
String condition = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "condition");
if (!condition.isEmpty())
meta.put("condition", condition);
@@ -73,11 +70,11 @@ public class SpringComponentDetector {
.build());
} else if (typeName.endsWith("Around") || typeName.endsWith("Before") || typeName.endsWith("After") || typeName.endsWith("AfterReturning") || typeName.endsWith("AfterThrowing")) {
Map<String, String> meta = new HashMap<>();
String value = extractAnnotationValue(annotation, "value");
String value = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "value");
if (!value.isEmpty())
meta.put("pointcut", value);
else {
String pointcut = extractAnnotationValue(annotation, "pointcut");
String pointcut = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(annotation, "pointcut");
if (!pointcut.isEmpty())
meta.put("pointcut", pointcut);
}
@@ -98,20 +95,4 @@ public class SpringComponentDetector {
});
return entryPoints;
}
private String extractAnnotationValue(Annotation annotation, String memberName) {
if (annotation instanceof SingleMemberAnnotation sma) {
if ("value".equals(memberName)) {
return sma.getValue().toString();
}
} else if (annotation instanceof NormalAnnotation na) {
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if (pair.getName().getIdentifier().equals(memberName)) {
return pair.getValue().toString();
}
}
}
return "";
}
}

View File

@@ -4,6 +4,8 @@ import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
import org.eclipse.jdt.core.dom.WildcardType;
public class TypeResolver {
private final CodebaseContext context;
@@ -13,6 +15,73 @@ public class TypeResolver {
this.context = context;
}
public String resolveTypeToFqn(Type type, ASTNode contextNode) {
CompilationUnit cu = contextNode != null && contextNode.getRoot() instanceof CompilationUnit rootCu
? rootCu
: null;
return resolveTypeToFqn(type, cu, Map.of());
}
public String resolveTypeToFqn(Type type, CompilationUnit cu) {
return resolveTypeToFqn(type, cu, Map.of());
}
public String resolveTypeToFqn(Type type, CompilationUnit cu, Map<String, String> typeVarBindings) {
if (type == null) {
return null;
}
if (type instanceof WildcardType wildcardType) {
Type bound = wildcardType.getBound();
return bound != null ? resolveTypeToFqn(bound, cu, typeVarBindings) : null;
}
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), cu, typeVarBindings);
} else {
simpleName = type.toString();
}
if (simpleName.contains("<")) {
simpleName = simpleName.substring(0, simpleName.indexOf('<'));
}
if (typeVarBindings.containsKey(simpleName)) {
return typeVarBindings.get(simpleName);
}
if (cu != null) {
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
if (!packageName.isEmpty()) {
AbstractTypeDeclaration localTd = context.getAbstractTypeDeclaration(packageName + "." + simpleName);
if (localTd != null) {
return context.getFqn(localTd);
}
}
} else {
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName);
if (td != null) {
return context.getFqn(td);
}
}
return simpleName;
}
public int getParameterIndex(String methodFqn, String paramName) {
return getParameterIndex(methodFqn, paramName, false);
}

View File

@@ -1,9 +1,11 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.index.AccessorNaming;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.index.TrivialAccessorDetector;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
@@ -18,11 +20,13 @@ public class VariableTracer {
private final click.kamil.springstatemachineexporter.ast.common.DataFlowModel dataFlowModel;
private final Map<String, String> variableDeclaredTypeCache = new HashMap<>();
private final FieldInitializerFinder fieldInitializerFinder;
public VariableTracer(CodebaseContext context, ConstantResolver constantResolver) {
this.context = context;
this.constantResolver = constantResolver;
this.dataFlowModel = new click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel(context);
this.fieldInitializerFinder = new FieldInitializerFinder(context);
}
public void setConstantExtractor(ConstantExtractor constantExtractor) {
@@ -74,9 +78,9 @@ public class VariableTracer {
String setterMethodName;
String indexedFieldName;
if (isBeanStyleAccessorName(getterName)) {
String propName = propertyNameFromAccessor(getterName);
setterMethodName = "set" + capitalizeProperty(propName);
if (AccessorNaming.isBeanStyleAccessorName(getterName)) {
String propName = AccessorNaming.propertyNameFromAccessor(getterName);
setterMethodName = "set" + AccessorNaming.capitalizeProperty(propName);
indexedFieldName = propName;
String varTypeName = getVariableDeclaredType(methodFqn, varName);
@@ -110,32 +114,6 @@ public class VariableTracer {
return null;
}
private static boolean isBeanStyleAccessorName(String accessorName) {
return (accessorName.startsWith("get") && accessorName.length() > 3)
|| (accessorName.startsWith("is") && accessorName.length() > 2)
|| (accessorName.startsWith("set") && accessorName.length() > 3);
}
private static String propertyNameFromAccessor(String getterName) {
if (getterName.startsWith("get") && getterName.length() > 3) {
return TrivialAccessorDetector.decapitalize(getterName.substring(3));
}
if (getterName.startsWith("is") && getterName.length() > 2) {
return TrivialAccessorDetector.decapitalize(getterName.substring(2));
}
return getterName;
}
private static String capitalizeProperty(String propertyName) {
if (propertyName == null || propertyName.isEmpty()) {
return propertyName;
}
if (propertyName.length() > 1 && Character.isUpperCase(propertyName.charAt(1))) {
return propertyName;
}
return Character.toUpperCase(propertyName.charAt(0)) + propertyName.substring(1);
}
private String getFieldType(TypeDeclaration td, String fieldName, CodebaseContext context, java.util.Set<String> visited) {
if (td == null) {
return null;
@@ -150,55 +128,15 @@ public class VariableTracer {
return indexed.get();
}
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(fieldName)) {
return fd.getType().toString();
}
}
}
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
if (superTd != null) {
String result = getFieldType(superTd, fieldName, context, visited);
if (result != null) return result;
}
}
for (Object intfObj : td.superInterfaceTypes()) {
Type intfType = (Type) intfObj;
String intfName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(intfType);
String resolvedIntf = resolveTypeFqn(intfName, context, td);
if (resolvedIntf != null) {
TypeDeclaration intfTd = context.getTypeDeclaration(resolvedIntf);
if (intfTd != null) {
String result = getFieldType(intfTd, fieldName, context, visited);
if (result != null) return result;
}
}
}
return null;
return fieldInitializerFinder.resolveFieldTypeName(td, fieldName, visited);
}
private String resolveTypeFqn(String simpleName, CodebaseContext context, TypeDeclaration td) {
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
if (cu != null) {
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String name = imp.getName().getFullyQualifiedName();
if (name.endsWith("." + simpleName)) {
return name;
}
}
if (cu.getPackage() != null) {
return cu.getPackage().getName().getFullyQualifiedName() + "." + simpleName;
}
}
return simpleName;
private MethodDeclaration findEnclosingMethod(ASTNode node) {
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(node);
}
private TypeDeclaration findEnclosingType(ASTNode node) {
return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node);
}
public String getVariableDeclaredType(String methodFqn, String cleanFieldName) {
@@ -693,22 +631,6 @@ public class VariableTracer {
return true;
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
private Expression unwrapMethodInvocation(MethodInvocation mi, int depth, String methodFqn) {
return MethodInvocationUnwrapper.unwrap(mi, depth, methodFqn, context, miArg -> resolveTargetMethodFqn(miArg, methodFqn));
}

View File

@@ -31,7 +31,17 @@ public class SpringDependencyResolver {
.filter(bean -> requiredTypeFqn.equals(bean.getTypeFqn()) || bean.getAssignableTypes().contains(requiredTypeFqn))
.collect(Collectors.toList());
log.debug("CANDIDATES AFTER TYPE: {}", candidates.stream().map(c -> c.getTypeFqn() + " (primary=" + c.isPrimary() + " names=" + c.getBeanNames() + " qual=" + c.getQualifiers() + ")").collect(Collectors.toList()));
log.debug("CANDIDATES AFTER TYPE: {}", candidates.size());
if (log.isDebugEnabled()) {
log.debug(
"CANDIDATE DETAILS: {}",
candidates.stream()
.map(c -> c.getTypeFqn()
+ " (primary=" + c.isPrimary()
+ " names=" + c.getBeanNames()
+ " qual=" + c.getQualifiers() + ")")
.collect(Collectors.toList()));
}
if (candidates.isEmpty() || candidates.size() == 1) {
log.debug("RETURNING: {}", candidates.size());
return candidates;

View File

@@ -7,6 +7,7 @@ import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.Transition;
import click.kamil.springstatemachineexporter.model.TransitionType;
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList;
@@ -349,7 +350,7 @@ public class AstTransitionParser {
}
} else {
// FALLBACK: Manual resolution by name if binding failed
TypeDeclaration currentClass = findEnclosingType(mi);
TypeDeclaration currentClass = AstUtils.findEnclosingType(mi);
if (currentClass != null) {
return resolveMethodManually(mi.getName().getIdentifier(), currentClass, context);
}
@@ -393,7 +394,7 @@ public class AstTransitionParser {
typeFqn = binding.getQualifiedName();
} else if (expr instanceof SimpleName sn) {
// Check enclosing method first for local variables
MethodDeclaration enclosingMethod = findEnclosingMethod(expr);
MethodDeclaration enclosingMethod = AstUtils.findEnclosingMethod(expr);
if (enclosingMethod != null) {
final String[] foundType = {null};
final Expression[] foundInitializer = {null};
@@ -424,7 +425,7 @@ public class AstTransitionParser {
}
if (typeFqn == null) {
TypeDeclaration enclosingClass = findEnclosingType(expr);
TypeDeclaration enclosingClass = AstUtils.findEnclosingType(expr);
if (enclosingClass != null) {
for (FieldDeclaration fd : enclosingClass.getFields()) {
for (Object fragObj : fd.fragments()) {
@@ -480,15 +481,6 @@ public class AstTransitionParser {
return null;
}
private static MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode current = node;
while (current != null) {
if (current instanceof MethodDeclaration md) return md;
current = current.getParent();
}
return null;
}
private static String resolveMethodManually(String methodName, TypeDeclaration td, CodebaseContext context) {
// Search in this class
for (MethodDeclaration md : td.getMethods()) {
@@ -520,21 +512,12 @@ public class AstTransitionParser {
return null;
}
private static TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode current = node;
while (current != null) {
if (current instanceof TypeDeclaration td) return td;
current = current.getParent();
}
return null;
}
private static void parseGuard(Object arg, Transition t, CompilationUnit cu, CodebaseContext context) {
QuotedExpression quotedExpr = QuotedExpression.of(arg);
if (quotedExpr != null) {
String internalLogic = extractInternalLogic(quotedExpr.getExpression(), cu, context);
int lineNumber = cu.getLineNumber(quotedExpr.getExpression().getStartPosition());
TypeDeclaration td = findEnclosingType(quotedExpr.getExpression());
TypeDeclaration td = AstUtils.findEnclosingType(quotedExpr.getExpression());
String fqn = td != null ? context.getFqn(td) : null;
String sourceFile = fqn != null ? context.getRelativePath(fqn) : null;
t.getGuards().add(Guard.of(quotedExpr.toStringWithoutQuotes(), isLambdaOrAnonymous(quotedExpr.getExpression()), internalLogic, lineNumber, fqn, sourceFile));
@@ -546,7 +529,7 @@ public class AstTransitionParser {
if (quotedExpr != null) {
String internalLogic = extractInternalLogic(quotedExpr.getExpression(), cu, context);
int lineNumber = cu.getLineNumber(quotedExpr.getExpression().getStartPosition());
TypeDeclaration td = findEnclosingType(quotedExpr.getExpression());
TypeDeclaration td = AstUtils.findEnclosingType(quotedExpr.getExpression());
String fqn = td != null ? context.getFqn(td) : null;
String sourceFile = fqn != null ? context.getRelativePath(fqn) : null;
t.getActions().add(Action.of(quotedExpr.toStringWithoutQuotes(), isLambdaOrAnonymous(quotedExpr.getExpression()), internalLogic, lineNumber, fqn, sourceFile));

View File

@@ -1,7 +1,13 @@
package click.kamil.springstatemachineexporter.ast.common;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.ArrayType;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.MemberValuePair;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.Name;
import org.eclipse.jdt.core.dom.NormalAnnotation;
import org.eclipse.jdt.core.dom.SingleMemberAnnotation;
import org.eclipse.jdt.core.dom.NameQualifiedType;
import org.eclipse.jdt.core.dom.ParameterizedType;
import org.eclipse.jdt.core.dom.PrimitiveType;
@@ -9,11 +15,59 @@ import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.QualifiedType;
import org.eclipse.jdt.core.dom.SimpleType;
import org.eclipse.jdt.core.dom.Type;
import org.eclipse.jdt.core.dom.TypeDeclaration;
public final class AstUtils {
private AstUtils() {
}
public static MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
}
public static TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
public static String simpleName(String fqn) {
if (fqn == null || fqn.isEmpty()) {
return fqn;
}
int dot = fqn.lastIndexOf('.');
return dot >= 0 ? fqn.substring(dot + 1) : fqn;
}
public static String qualifier(String fqn) {
if (fqn == null || !fqn.contains(".")) {
return null;
}
return fqn.substring(0, fqn.lastIndexOf('.'));
}
public static String extractAnnotationMember(Annotation annotation, String memberName) {
if (annotation instanceof SingleMemberAnnotation sma) {
if ("value".equals(memberName)) {
return sma.getValue().toString();
}
} else if (annotation instanceof NormalAnnotation na) {
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if (pair.getName().getIdentifier().equals(memberName)) {
return pair.getValue().toString();
}
}
}
return "";
}
public static String extractSimpleTypeName(Type type) {
if (type.isSimpleType()) {
Name name = ((SimpleType) type).getName();

View File

@@ -14,7 +14,6 @@ public class JdtDataFlowModel implements DataFlowModel {
private final Map<MethodDeclaration, ControlFlowGraph> cfgCache = new HashMap<>();
private final Map<MethodDeclaration, ReachingDefinitions> rdCache = new HashMap<>();
private final Map<ASTNode, Map<IVariableBinding, Expression>> objectFieldBindings = new HashMap<>();
private final Map<String, Set<String>> compatibleContextTypesCache = new HashMap<>();
private static final ThreadLocal<List<String>> CURRENT_PATH = new ThreadLocal<>();
@@ -35,18 +34,11 @@ public class JdtDataFlowModel implements DataFlowModel {
}
public void clearAnalysisCaches() {
compatibleContextTypesCache.clear();
context.getClassCompatibilityCache().clearMemoCache();
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode current = node;
while (current != null) {
if (current instanceof MethodDeclaration) {
return (MethodDeclaration) current;
}
current = current.getParent();
}
return null;
return AstUtils.findEnclosingMethod(node);
}
private ReachingDefinitions getReachingDefinitionsForMethod(MethodDeclaration method) {
@@ -1785,8 +1777,8 @@ public class JdtDataFlowModel implements DataFlowModel {
}
private Set<String> getCompatibleContextTypes(List<String> currentPath, String declaringClassFqn) {
String cacheKey = String.valueOf(currentPath) + "#" + declaringClassFqn;
return compatibleContextTypesCache.computeIfAbsent(
String cacheKey = "contextTypes:" + currentPath + "#" + declaringClassFqn;
return context.getClassCompatibilityCache().memoize(
cacheKey, ignored -> computeCompatibleContextTypes(currentPath, declaringClassFqn));
}

View File

@@ -1,31 +0,0 @@
package click.kamil.springstatemachineexporter.ast.common;
import org.eclipse.jdt.core.dom.Expression;
import click.kamil.springstatemachineexporter.analysis.service.VariableTracer;
import java.util.List;
public class LegacyDataFlowModel implements DataFlowModel {
private final VariableTracer variableTracer;
public LegacyDataFlowModel(VariableTracer variableTracer) {
this.variableTracer = variableTracer;
}
@Override
public List<Expression> getReachingDefinitions(Expression expr) {
if (variableTracer == null || expr == null) {
return expr != null ? List.of(expr) : List.of();
}
return variableTracer.traceVariableAll(expr);
}
@Override
public String resolveValue(Expression expr, CodebaseContext context) {
if (expr == null) return null;
if (variableTracer == null) {
return context.resolveExpression(expr);
}
Expression traced = variableTracer.traceVariable(expr);
return context.resolveExpression(traced);
}
}

View File

@@ -23,7 +23,7 @@ public class StateResolver {
// 2. Check for @Value fields (SimpleName)
if (expr instanceof SimpleName sn) {
String fieldName = sn.getIdentifier();
TypeDeclaration td = findEnclosingType(sn);
TypeDeclaration td = click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(sn);
if (td != null) {
String value = findValueFromField(td, fieldName);
if (value != null) {
@@ -69,21 +69,11 @@ public class StateResolver {
}
private String extractAnnotationValue(Annotation ann) {
String raw;
if (ann instanceof SingleMemberAnnotation sma) {
raw = stripQuotes(sma.getValue().toString());
} else if (ann instanceof NormalAnnotation na) {
raw = null;
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if ("value".equals(pair.getName().getIdentifier())) {
raw = stripQuotes(pair.getValue().toString());
break;
}
}
} else {
String raw = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractAnnotationMember(ann, "value");
if (raw.isEmpty()) {
return null;
}
raw = stripQuotes(raw);
if (raw != null && raw.contains("${")) {
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(
raw, java.util.Collections.emptyMap());
@@ -96,14 +86,6 @@ public class StateResolver {
return s.replaceAll("^\"|\"$", "");
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
private String resolveQualifiedName(QualifiedName qn, CompilationUnit cu) {
String qualifier = qn.getQualifier().toString();
String name = qn.getName().getIdentifier();

View File

@@ -177,20 +177,24 @@ public class ExportService {
}
if (sourceRoot != null) {
log.info("JSON re-export: scanning source project at {}", sourceRoot);
context = click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory
.scanProjectRoot(sourceRoot, activeProfiles);
try {
context = click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory
.scanProjectRoot(sourceRoot, activeProfiles);
} catch (RuntimeException ex) {
log.warn("JSON re-export: source scan failed; using embedded machine types if present", ex);
}
} else {
log.info("JSON re-export: no source project found; using embedded machine types if present");
}
enrichmentService.relinkAfterPropertyResolution(result, context, null);
StateMachineTypeResolver.MachineTypes machineTypes =
click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory.resolveMachineTypes(
result.getName(),
result.getStateTypeFqn(),
result.getEventTypeFqn(),
context);
AnalysisResultFinalizer.applyCanonicalization(result, machineTypes);
enrichmentService.relinkAfterPropertyResolution(result, context, null);
AnalysisResultFinalizer.finalizeResult(result, context, machineTypes);
generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat);

View File

@@ -0,0 +1,133 @@
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.EntryPoint;
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.Event;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class EnricherPipelineRegressionTest {
@Test
void shouldCanonicalizeChainTriggerEventsBeforeLinker(@TempDir Path tempDir) throws IOException {
writeSampleMachine(tempDir);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TriggerPoint rawTrigger = TriggerPoint.builder()
.className("com.example.web.OrderController")
.methodName("pay")
.event("OrderEvent.PAY")
.sourceState("OrderState.NEW")
.eventTypeFqn("OrderEvent")
.stateTypeFqn("OrderState")
.build();
CallChain rawChain = CallChain.builder()
.triggerPoint(rawTrigger)
.build();
CodebaseIntelligenceProvider intelligence = new CodebaseIntelligenceProvider() {
@Override
public List<TriggerPoint> findTriggerPoints() {
return List.of(rawTrigger);
}
@Override
public List<EntryPoint> findEntryPoints() {
return List.of();
}
@Override
public List<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
return List.of(rawChain);
}
@Override
public Map<String, Map<String, String>> resolveProperties() {
return Map.of();
}
};
Transition transition = new Transition();
transition.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
transition.setSourceStates(List.of(State.of("OrderState.NEW", "com.example.order.OrderState.NEW")));
transition.setTargetStates(List.of(State.of("OrderState.PAID", "com.example.order.OrderState.PAID")));
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.transitions(List.of(transition))
.metadata(CodebaseMetadata.builder()
.entryPoints(List.of())
.triggers(List.of(rawTrigger))
.build())
.build();
new CallChainEnricher().enrich(result, context, intelligence);
new TriggerCanonicalizationEnricher().enrich(result, context, intelligence);
new TransitionLinkerEnricher().enrich(result, context, intelligence);
TriggerPoint chainTrigger = result.getMetadata().getCallChains().get(0).getTriggerPoint();
assertThat(chainTrigger.getEvent()).isEqualTo("com.example.order.OrderEvent.PAY");
assertThat(chainTrigger.getSourceState()).isEqualTo("com.example.order.OrderState.NEW");
assertThat(result.getMetadata().getTriggers().get(0).getEvent())
.isEqualTo("com.example.order.OrderEvent.PAY");
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getEvent())
.isEqualTo("com.example.order.OrderEvent.PAY");
}
private static void writeSampleMachine(Path tempDir) throws IOException {
Path orderPkg = tempDir.resolve("com/example/order");
Path configPkg = tempDir.resolve("com/example/config");
Path webPkg = tempDir.resolve("com/example/web");
Files.createDirectories(orderPkg);
Files.createDirectories(configPkg);
Files.createDirectories(webPkg);
Files.writeString(orderPkg.resolve("OrderState.java"),
"""
package com.example.order;
public enum OrderState { NEW, PAID }
""");
Files.writeString(orderPkg.resolve("OrderEvent.java"),
"""
package com.example.order;
public enum OrderEvent { PAY }
""");
Files.writeString(configPkg.resolve("OrderStateMachineConfiguration.java"),
"""
package com.example.config;
import com.example.order.OrderEvent;
import com.example.order.OrderState;
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
public class OrderStateMachineConfiguration
extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
}
""");
Files.writeString(webPkg.resolve("OrderController.java"),
"""
package com.example.web;
public class OrderController {
public void pay() {}
}
""");
}
}

View File

@@ -149,6 +149,46 @@ class MachineEnumCanonicalizerTest {
.isEqualTo("getType()");
}
@Test
void shouldSyncFnFormRawNameWhenFullIdentifierAlreadyCanonical(@TempDir Path tempDir) throws IOException {
writeSampleConfig(tempDir);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
StateMachineTypeResolver.MachineTypes types = StateMachineTypeResolver.resolveTypes(
"com.example.config.OrderStateMachineConfiguration", context);
State mismatched = State.of("UNDEFINED", "com.example.order.OrderState.NEW");
State canonical = MachineEnumCanonicalizer.canonicalizeStates(Set.of(mismatched), types.stateTypeFqn())
.iterator().next();
assertThat(canonical.fullIdentifier()).isEqualTo("com.example.order.OrderState.NEW");
assertThat(canonical.rawName()).isEqualTo("OrderState.NEW");
}
@Test
void shouldDedupeStatesByCanonicalFullIdentifier(@TempDir Path tempDir) throws IOException {
writeSampleConfig(tempDir);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
StateMachineTypeResolver.MachineTypes types = StateMachineTypeResolver.resolveTypes(
"com.example.config.OrderStateMachineConfiguration", context);
State shortForm = State.of("OrderState.NEW", "OrderState.NEW");
State packageForm = State.of("UNDEFINED", "com.example.order.OrderState.NEW");
Set<State> deduped = MachineEnumCanonicalizer.canonicalizeStates(
Set.of(shortForm, packageForm), types.stateTypeFqn());
assertThat(deduped).hasSize(1);
State only = deduped.iterator().next();
assertThat(only.fullIdentifier()).isEqualTo("com.example.order.OrderState.NEW");
assertThat(only.rawName()).isEqualTo("OrderState.NEW");
}
private static void writeSampleConfig(Path tempDir) throws IOException {
Path orderPkg = tempDir.resolve("com/example/order");
Path configPkg = tempDir.resolve("com/example/config");

View File

@@ -0,0 +1,374 @@
package click.kamil.springstatemachineexporter.analysis.service;
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.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.exporter.ExportOptions;
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import click.kamil.springstatemachineexporter.service.ExportService;
import click.kamil.springstatemachineexporter.service.JsonImportService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Regression gate: after the full {@link ExportService} pipeline, enum-backed machines must not
* contain mixed short-form ({@code OrderState.NEW}) and package-canonical
* ({@code com.example.order.OrderState.NEW}) identifiers in the same export.
*/
class ExportPipelineIdentifierConsistencyTest {
@Test
void jsonReExportShouldNotMixShortAndPackageCanonicalStateIdentifiers(@TempDir Path tempDir) throws Exception {
writeSampleProject(tempDir);
TriggerPoint trigger = TriggerPoint.builder()
.event("OrderEvent.PAY")
.sourceState("OrderState.NEW")
.className("com.example.web.OrderController")
.methodName("pay")
.sourceFile("OrderController.java")
.polymorphicEvents(List.of("OrderEvent.PAY"))
.build();
CallChain chain = CallChain.builder().triggerPoint(trigger).build();
AnalysisResult shortForm = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.stateTypeFqn("com.example.order.OrderState")
.eventTypeFqn("com.example.order.OrderEvent")
.states(Set.of(
State.of("OrderState.NEW", "OrderState.NEW"),
State.of("UNDEFINED", "com.example.order.OrderState.PAID")))
.transitions(List.of(
shortTransition("OrderEvent.PAY", "OrderState.NEW", "OrderState.PAID"),
multiSourceTransition("OrderEvent.PAY", List.of("OrderState.NEW", "OrderState.PAID"), "OrderState.PAID")))
.startStates(Set.of("OrderState.NEW"))
.endStates(Set.of("OrderState.PAID"))
.metadata(CodebaseMetadata.builder()
.triggers(List.of(trigger))
.callChains(List.of(chain))
.properties(Map.of("default", Map.of(
"app.event", "OrderEvent.PAY",
"app.state", "OrderState.NEW")))
.build())
.build();
Path jsonFile = tempDir.resolve("machine.json");
Files.writeString(jsonFile, new JsonExporter().export(shortForm, ExportOptions.builder().build()));
Path outputDir = tempDir.resolve("out");
ExportService exportService = new ExportService(List.of(new JsonExporter()));
exportService.runJsonExporter(jsonFile, outputDir, List.of("json"), List.of("default"));
AnalysisResult exported = new JsonImportService().importAnalysisResult(
outputDir.resolve("com.example.config.OrderStateMachineConfiguration")
.resolve("com.example.config.OrderStateMachineConfiguration.json"));
assertNoMixedEnumIdentifiers(exported);
assertThat(exported.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNotEmpty();
}
@Test
void astExportShouldNotMixShortAndPackageCanonicalStateIdentifiers(@TempDir Path tempDir) throws Exception {
Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise");
Path outputDir = tempDir.resolve("enterprise-out");
ExportService exportService = new ExportService(List.of(new JsonExporter()));
exportService.runExporter(enterprise, outputDir, List.of("json"), true, List.of());
ObjectMapper mapper = new ObjectMapper();
try (var dirs = Files.list(outputDir)) {
List<Path> machineDirs = dirs.filter(Files::isDirectory).toList();
assertThat(machineDirs).isNotEmpty();
for (Path machineDir : machineDirs) {
Path json = machineDir.resolve(machineDir.getFileName() + ".json");
JsonNode root = mapper.readTree(json.toFile());
if (!root.hasNonNull("stateTypeFqn") || !root.get("stateTypeFqn").asText().contains(".")) {
continue;
}
AnalysisResult result = new JsonImportService().importAnalysisResult(json);
assertNoMixedEnumIdentifiers(result);
}
}
}
@Test
void propertyResolutionShouldCanonicalizeCallChainTriggersBeforeRelink(@TempDir Path tempDir) throws Exception {
writeSampleProject(tempDir);
TriggerPoint trigger = TriggerPoint.builder()
.event("${app.event}")
.sourceState("${app.state}")
.className("com.example.web.OrderController")
.methodName("pay")
.sourceFile("OrderController.java")
.polymorphicEvents(List.of("${app.event}"))
.eventTypeFqn("com.example.order.OrderEvent")
.stateTypeFqn("com.example.order.OrderState")
.build();
CallChain chain = CallChain.builder()
.triggerPoint(trigger)
.matchedTransitions(List.of(MatchedTransition.builder()
.event("OrderEvent.PAY")
.sourceState("OrderState.NEW")
.targetState("OrderState.PAID")
.build()))
.build();
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.stateTypeFqn("com.example.order.OrderState")
.eventTypeFqn("com.example.order.OrderEvent")
.transitions(List.of(shortTransition("OrderEvent.PAY", "OrderState.NEW", "OrderState.PAID")))
.metadata(CodebaseMetadata.builder()
.triggers(List.of(trigger))
.callChains(List.of(chain))
.properties(Map.of("default", Map.of(
"app.event", "OrderEvent.PAY",
"app.state", "OrderState.NEW")))
.build())
.build();
result.applyResolution(Map.of("app.event", "OrderEvent.PAY", "app.state", "OrderState.NEW"));
TriggerPoint chainTrigger = result.getMetadata().getCallChains().get(0).getTriggerPoint();
assertThat(chainTrigger.getEvent()).isEqualTo("com.example.order.OrderEvent.PAY");
assertThat(chainTrigger.getSourceState()).isEqualTo("com.example.order.OrderState.NEW");
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getSourceState())
.isEqualTo("com.example.order.OrderState.NEW");
assertNoMixedEnumIdentifiers(result);
}
static void assertNoMixedEnumIdentifiers(AnalysisResult result) {
String stateTypeFqn = result.getStateTypeFqn();
String eventTypeFqn = result.getEventTypeFqn();
if (stateTypeFqn == null || !stateTypeFqn.contains(".")
|| MachineEnumCanonicalizer.isStringOrPrimitiveType(stateTypeFqn)) {
return;
}
if (eventTypeFqn == null || !eventTypeFqn.contains(".")
|| MachineEnumCanonicalizer.isStringOrPrimitiveType(eventTypeFqn)) {
return;
}
Set<String> stateIds = collectStateIdentifiers(result);
Set<String> eventIds = collectEventIdentifiers(result);
assertCanonicalSet(stateIds, stateTypeFqn, "state");
assertCanonicalSet(eventIds, eventTypeFqn, "event");
assertNoShortFormWhenPackageFormPresent(stateIds, stateTypeFqn);
assertNoShortFormWhenPackageFormPresent(eventIds, eventTypeFqn);
}
private static void assertCanonicalSet(Set<String> identifiers, String enumTypeFqn, String label) {
for (String id : identifiers) {
if (id == null || id.isBlank() || id.startsWith("<") || id.startsWith("\"")) {
continue;
}
if (!MachineEnumCanonicalizer.isMachineEnumReference(id, enumTypeFqn)) {
continue;
}
assertThat(id)
.as("%s identifier must be package-canonical: %s", label, id)
.isEqualTo(MachineEnumCanonicalizer.canonicalizeLabel(id, enumTypeFqn));
}
}
private static void assertNoShortFormWhenPackageFormPresent(Set<String> identifiers, String enumTypeFqn) {
Set<String> packageForms = new HashSet<>();
Set<String> shortForms = new HashSet<>();
for (String id : identifiers) {
if (id == null || !MachineEnumCanonicalizer.isMachineEnumReference(id, enumTypeFqn)) {
continue;
}
if (id.startsWith(enumTypeFqn + ".")) {
packageForms.add(id);
String fn = fnForm(id);
if (fn != null) {
shortForms.add(fn);
}
} else {
shortForms.add(id);
}
}
for (String pkg : packageForms) {
String fn = fnForm(pkg);
assertThat(identifiers)
.as("mixed short/package identifiers for %s", pkg)
.doesNotContain(fn);
}
assertThat(shortForms.stream().filter(id -> !id.startsWith(enumTypeFqn + ".")).toList())
.as("unexpected bare short forms while package forms exist: %s vs %s", shortForms, packageForms)
.isEmpty();
}
private static String fnForm(String identifier) {
if (identifier == null || identifier.isBlank()) {
return identifier;
}
int lastDot = identifier.lastIndexOf('.');
if (lastDot <= 0) {
return identifier;
}
int prevDot = identifier.lastIndexOf('.', lastDot - 1);
return prevDot > 0 ? identifier.substring(prevDot + 1) : identifier;
}
private static Set<String> collectStateIdentifiers(AnalysisResult result) {
Set<String> ids = new LinkedHashSet<>();
if (result.getStates() != null) {
result.getStates().forEach(s -> addIfPresent(ids, s.fullIdentifier()));
}
if (result.getStartStates() != null) {
ids.addAll(result.getStartStates());
}
if (result.getEndStates() != null) {
ids.addAll(result.getEndStates());
}
if (result.getTransitions() != null) {
for (Transition t : result.getTransitions()) {
addStates(ids, t.getSourceStates());
addStates(ids, t.getTargetStates());
}
}
if (result.getMetadata() != null) {
if (result.getMetadata().getTriggers() != null) {
for (TriggerPoint trigger : result.getMetadata().getTriggers()) {
addIfPresent(ids, trigger.getSourceState());
}
}
if (result.getMetadata().getCallChains() != null) {
for (CallChain chain : result.getMetadata().getCallChains()) {
if (chain.getTriggerPoint() != null) {
addIfPresent(ids, chain.getTriggerPoint().getSourceState());
}
if (chain.getMatchedTransitions() != null) {
for (MatchedTransition mt : chain.getMatchedTransitions()) {
addIfPresent(ids, mt.getSourceState());
addIfPresent(ids, mt.getTargetState());
}
}
}
}
}
ids.removeIf(id -> id == null || id.isBlank());
return ids;
}
private static Set<String> collectEventIdentifiers(AnalysisResult result) {
Set<String> ids = new LinkedHashSet<>();
if (result.getTransitions() != null) {
for (Transition t : result.getTransitions()) {
if (t.getEvent() != null) {
addIfPresent(ids, t.getEvent().fullIdentifier());
}
}
}
if (result.getMetadata() != null) {
if (result.getMetadata().getTriggers() != null) {
for (TriggerPoint trigger : result.getMetadata().getTriggers()) {
addIfPresent(ids, trigger.getEvent());
if (trigger.getPolymorphicEvents() != null) {
ids.addAll(trigger.getPolymorphicEvents());
}
}
}
if (result.getMetadata().getCallChains() != null) {
for (CallChain chain : result.getMetadata().getCallChains()) {
if (chain.getTriggerPoint() != null) {
addIfPresent(ids, chain.getTriggerPoint().getEvent());
if (chain.getTriggerPoint().getPolymorphicEvents() != null) {
ids.addAll(chain.getTriggerPoint().getPolymorphicEvents());
}
}
if (chain.getMatchedTransitions() != null) {
for (MatchedTransition mt : chain.getMatchedTransitions()) {
addIfPresent(ids, mt.getEvent());
}
}
}
}
}
ids.removeIf(id -> id == null || id.isBlank());
return ids;
}
private static void addStates(Set<String> ids, List<State> states) {
if (states == null) {
return;
}
for (State state : states) {
addIfPresent(ids, state.fullIdentifier());
}
}
private static void addIfPresent(Set<String> ids, String value) {
if (value != null && !value.isBlank()) {
ids.add(value);
}
}
private static Transition shortTransition(String event, String source, String target) {
Transition transition = new Transition();
transition.setEvent(Event.of(event, event));
transition.setSourceStates(List.of(State.of(source, source)));
transition.setTargetStates(List.of(State.of(target, target)));
return transition;
}
private static Transition multiSourceTransition(String event, List<String> sources, String target) {
Transition transition = new Transition();
transition.setEvent(Event.of(event, event));
transition.setSourceStates(sources.stream().map(s -> State.of(s, s)).toList());
transition.setTargetStates(List.of(State.of(target, target)));
return transition;
}
private static void writeSampleProject(Path projectRoot) throws Exception {
Path javaRoot = projectRoot.resolve("src/main/java");
Path orderPkg = javaRoot.resolve("com/example/order");
Path configPkg = javaRoot.resolve("com/example/config");
Files.createDirectories(orderPkg);
Files.createDirectories(configPkg);
Files.writeString(projectRoot.resolve("build.gradle"), "plugins { id 'java' }");
Files.writeString(orderPkg.resolve("OrderState.java"),
"package com.example.order; public enum OrderState { NEW, PAID }");
Files.writeString(orderPkg.resolve("OrderEvent.java"),
"package com.example.order; public enum OrderEvent { PAY }");
Files.writeString(configPkg.resolve("OrderStateMachineConfiguration.java"),
"""
package com.example.config;
import com.example.order.OrderEvent;
import com.example.order.OrderState;
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
public class OrderStateMachineConfiguration
extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
}
""");
}
private static Path findProjectRoot() {
Path current = Path.of(".").toAbsolutePath();
while (current != null && !Files.exists(current.resolve("settings.gradle"))) {
current = current.getParent();
}
return current;
}
}

View File

@@ -1,6 +1,7 @@
package click.kamil.springstatemachineexporter.analysis.service;
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.TriggerPoint;
import click.kamil.springstatemachineexporter.exporter.ExportOptions;
@@ -16,6 +17,7 @@ import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@@ -95,6 +97,75 @@ class JsonRoundTripCanonicalizationTest {
.isEqualTo("com.example.order.OrderEvent.PAY");
}
@Test
void shouldRelinkMatchedTransitionsAfterPreCanonicalizingShortFormJson(@TempDir Path tempDir) throws Exception {
writeSampleProject(tempDir);
TriggerPoint trigger = TriggerPoint.builder()
.event("OrderEvent.PAY")
.sourceState("OrderState.NEW")
.className("com.example.web.OrderController")
.methodName("pay")
.sourceFile("OrderController.java")
.build();
CallChain chain = CallChain.builder()
.triggerPoint(trigger)
.build();
AnalysisResult shortForm = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.stateTypeFqn("com.example.order.OrderState")
.eventTypeFqn("com.example.order.OrderEvent")
.transitions(List.of(shortTransition(
"OrderEvent.PAY",
"OrderState.NEW",
"OrderState.PAID")))
.metadata(CodebaseMetadata.builder()
.triggers(List.of(trigger))
.callChains(List.of(chain))
.build())
.build();
Path jsonFile = tempDir.resolve("machine.json");
Files.writeString(jsonFile, new JsonExporter().export(shortForm, ExportOptions.builder().build()));
Path outputDir = tempDir.resolve("out");
ExportService exportService = new ExportService(List.of(new JsonExporter()));
exportService.runJsonExporter(jsonFile, outputDir, List.of("json"));
AnalysisResult roundTripped = new JsonImportService().importAnalysisResult(
outputDir.resolve("com.example.config.OrderStateMachineConfiguration")
.resolve("com.example.config.OrderStateMachineConfiguration.json"));
assertThat(roundTripped.getMetadata().getCallChains()).hasSize(1);
assertThat(roundTripped.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
assertThat(roundTripped.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getEvent())
.isEqualTo("com.example.order.OrderEvent.PAY");
assertThat(roundTripped.getMetadata().getCallChains().get(0).getMatchedTransitions().get(0).getSourceState())
.isEqualTo("com.example.order.OrderState.NEW");
}
@Test
void shouldDedupeStatesByFullIdentifierAfterPropertyResolution(@TempDir Path tempDir) throws Exception {
writeSampleProject(tempDir);
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.stateTypeFqn("com.example.order.OrderState")
.eventTypeFqn("com.example.order.OrderEvent")
.states(Set.of(
State.of("OrderState.NEW", "OrderState.NEW"),
State.of("UNDEFINED", "com.example.order.OrderState.NEW")))
.build();
result.applyResolution(Map.of());
assertThat(result.getStates()).hasSize(1);
State only = result.getStates().iterator().next();
assertThat(only.fullIdentifier()).isEqualTo("com.example.order.OrderState.NEW");
assertThat(only.rawName()).isEqualTo("OrderState.NEW");
}
@Test
void shouldFindProjectRootFromJsonDirectory(@TempDir Path tempDir) throws Exception {
Path projectRoot = tempDir.resolve("my-app");

View File

@@ -1,56 +0,0 @@
package click.kamil.springstatemachineexporter.ast.common;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.service.VariableTracer;
import org.eclipse.jdt.core.dom.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class LegacyDataFlowModelTest {
@Test
void testLegacyDataFlowResolution(@TempDir Path tempDir) throws IOException {
Path comFoo = tempDir.resolve("com/foo");
Files.createDirectories(comFoo);
Files.writeString(comFoo.resolve("Test.java"),
"package com.foo;\n" +
"public class Test {\n" +
" public void run() {\n" +
" String a = \"VAL1\";\n" +
" String b = a;\n" +
" }\n" +
"}"
);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration td = context.getTypeDeclaration("com.foo.Test");
assertThat(td).isNotNull();
MethodDeclaration md = td.getMethods()[0];
VariableDeclarationStatement bStmt = (VariableDeclarationStatement) md.getBody().statements().get(1);
VariableDeclarationFragment frag = (VariableDeclarationFragment) bStmt.fragments().get(0);
Expression initializer = frag.getInitializer(); // 'a' reference
VariableTracer tracer = new VariableTracer(context, new ConstantResolver());
DataFlowModel model = new LegacyDataFlowModel(tracer);
List<Expression> defs = model.getReachingDefinitions(initializer);
assertThat(defs).isNotEmpty();
Expression resolvedExpr = defs.get(defs.size() - 1);
assertThat(resolvedExpr).isInstanceOf(StringLiteral.class);
assertThat(((StringLiteral) resolvedExpr).getLiteralValue()).isEqualTo("VAL1");
String val = model.resolveValue(initializer, context);
assertThat(val).isEqualTo("VAL1");
}
}

View File

@@ -31,7 +31,7 @@ class ExtendedStateMachineLifecycleHtmlTest {
String html = Files.readString(htmlFile);
assertThat(html).contains("function formatLifecycleLabel");
assertThat(html).contains("POST /api/orders/resume");
assertThat(html).contains("POST /api/payment/{id}/capture");
assertThat(html).contains("Restore persisted state");
assertThat(html).contains("[LIFECYCLE:RESTORE]");
}

View File

@@ -0,0 +1,179 @@
package click.kamil.springstatemachineexporter.html;
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.exporter.ExportOptions;
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
import click.kamil.springstatemachineexporter.html.exporter.HtmlExporter;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import click.kamil.springstatemachineexporter.service.ExportService;
import click.kamil.springstatemachineexporter.service.JsonImportService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
/**
* End-to-end HTML link-key consistency: SVG {@code #link_*} anchors must align with
* {@code matchedTransitions} produced by the finalized export pipeline.
*/
class HtmlTransitionLinkConsistencyTest {
@Test
void jsonReExportHtmlShouldContainLinkKeysForEveryMatchedTransition(@TempDir Path tempDir) throws Exception {
writeSampleProject(tempDir);
TriggerPoint trigger = TriggerPoint.builder()
.event("OrderEvent.PAY")
.sourceState("OrderState.NEW")
.className("com.example.web.OrderController")
.methodName("pay")
.sourceFile("OrderController.java")
.polymorphicEvents(List.of("OrderEvent.PAY"))
.build();
CallChain chain = CallChain.builder().triggerPoint(trigger).build();
AnalysisResult shortForm = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.stateTypeFqn("com.example.order.OrderState")
.eventTypeFqn("com.example.order.OrderEvent")
.transitions(List.of(shortTransition("OrderEvent.PAY", "OrderState.NEW", "OrderState.PAID")))
.startStates(Set.of("OrderState.NEW"))
.endStates(Set.of("OrderState.PAID"))
.metadata(CodebaseMetadata.builder()
.triggers(List.of(trigger))
.callChains(List.of(chain))
.build())
.build();
Path jsonFile = tempDir.resolve("machine.json");
Files.writeString(jsonFile, new JsonExporter().export(shortForm, ExportOptions.builder().build()));
Path outputDir = tempDir.resolve("out");
ExportService exportService = new ExportService(List.of(new HtmlExporter(), new JsonExporter()));
exportService.runJsonExporter(jsonFile, outputDir, List.of("html", "json"));
Path machineDir = outputDir.resolve("com.example.config.OrderStateMachineConfiguration");
Path htmlFile = machineDir.resolve("com.example.config.OrderStateMachineConfiguration.html");
Path finalizedJson = machineDir.resolve("com.example.config.OrderStateMachineConfiguration.json");
assertThat(htmlFile).exists();
String html = Files.readString(htmlFile);
AnalysisResult finalized = new JsonImportService().importAnalysisResult(finalizedJson);
List<MatchedTransition> matched = finalized.getMetadata().getCallChains().stream()
.flatMap(c -> c.getMatchedTransitions() == null ? java.util.stream.Stream.empty()
: c.getMatchedTransitions().stream())
.toList();
assertThat(matched).isNotEmpty();
for (MatchedTransition mt : matched) {
String linkKey = buildLinkKey(mt.getSourceState(), mt.getEvent());
assertThat(html)
.as("HTML must contain SVG link for matched transition %s -> %s", mt.getSourceState(), mt.getEvent())
.contains("#link_" + linkKey);
}
assertThat(html).contains("OrderState_NEW__OrderEvent_PAY");
assertThat(html).contains("com.example.order.OrderEvent.PAY");
}
@Test
void enterpriseAstExportHtmlShouldContainTransitionLinkKeys(@TempDir Path tempDir) throws Exception {
Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise");
Path outputDir = tempDir.resolve("enterprise-out");
ExportService exportService = new ExportService(List.of(new HtmlExporter()));
exportService.runExporter(enterprise, outputDir, List.of("html"), true, List.of(), null,
"OrderStateMachineConfiguration");
Path machineDir = outputDir.resolve("click.kamil.enterprise.machines.order.OrderStateMachineConfiguration");
Path htmlFile = machineDir.resolve("click.kamil.enterprise.machines.order.OrderStateMachineConfiguration.html");
assertThat(htmlFile).exists();
String html = Files.readString(htmlFile);
assertThat(html).contains("OrderState_NEW__OrderEvent_PAY");
assertThat(html).contains("function buildLinkKey(source, event)");
assertThat(html).contains("\"matchedTransitions\"");
}
/** Mirrors {@code template.html} link-key derivation for regression checks. */
private static String buildLinkKey(String source, String event) {
if (event == null || event.isBlank()) {
return "";
}
String formattedEvent = formatForLinkId(event);
if (source == null || source.isBlank()) {
return "__" + normalize(formattedEvent);
}
return normalize(formatForLinkId(source)) + "__" + normalize(formattedEvent);
}
private static String formatForLinkId(String identifier) {
if (identifier == null || identifier.isBlank()) {
return "";
}
int lastDot = identifier.lastIndexOf('.');
if (lastDot <= 0) {
return identifier;
}
int prevDot = identifier.lastIndexOf('.', lastDot - 1);
return prevDot > 0 ? identifier.substring(prevDot + 1) : identifier;
}
private static String normalize(String s) {
if (s == null || s.isBlank()) {
return "";
}
return s.replaceAll("[^a-zA-Z0-9]", "_");
}
private static Transition shortTransition(String event, String source, String target) {
Transition transition = new Transition();
transition.setEvent(Event.of(event, event));
transition.setSourceStates(List.of(State.of(source, source)));
transition.setTargetStates(List.of(State.of(target, target)));
return transition;
}
private static void writeSampleProject(Path projectRoot) throws Exception {
Path javaRoot = projectRoot.resolve("src/main/java");
Path orderPkg = javaRoot.resolve("com/example/order");
Path configPkg = javaRoot.resolve("com/example/config");
Files.createDirectories(orderPkg);
Files.createDirectories(configPkg);
Files.writeString(projectRoot.resolve("build.gradle"), "plugins { id 'java' }");
Files.writeString(orderPkg.resolve("OrderState.java"),
"package com.example.order; public enum OrderState { NEW, PAID }");
Files.writeString(orderPkg.resolve("OrderEvent.java"),
"package com.example.order; public enum OrderEvent { PAY }");
Files.writeString(configPkg.resolve("OrderStateMachineConfiguration.java"),
"""
package com.example.config;
import com.example.order.OrderEvent;
import com.example.order.OrderState;
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
public class OrderStateMachineConfiguration
extends EnumStateMachineConfigurerAdapter<OrderState, OrderEvent> {
}
""");
}
private static Path findProjectRoot() {
Path current = Path.of(".").toAbsolutePath();
while (current != null && !Files.exists(current.resolve("settings.gradle"))) {
current = current.getParent();
}
return current;
}
}

View File

@@ -40,7 +40,7 @@ class JsonToHtmlInterchangeTest {
// SVG link IDs derive from fn-formatted enum labels; matchedTransitions use package-canonical values.
assertThat(html).contains("OrderState_NEW__OrderEvent_PAY");
assertThat(html).contains("click.kamil.enterprise.machines.order.OrderEvent.PAY");
assertThat(html).contains("POST /api/machine/order/pay");
assertThat(html).contains("POST /api/machine/{machineType}/transition/{event}");
assertThat(html).contains("\"stateTypeFqn\" : \"click.kamil.enterprise.machines.order.OrderState\"");
}
}