Fix CIC accessor resolution, concrete overrides, and reactive flatMap tracing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-12 07:06:55 +02:00
parent c2eff6d7bb
commit cc35546d01
12 changed files with 1176 additions and 38 deletions

View File

@@ -184,25 +184,42 @@ public final class AccessorResolver {
|| org.eclipse.jdt.core.dom.Modifier.isAbstract(ownerType.getModifiers()); || org.eclipse.jdt.core.dom.Modifier.isAbstract(ownerType.getModifiers());
if (widenToImplementations) { if (widenToImplementations) {
List<String> widened = new ArrayList<>(); List<String> results = new ArrayList<>();
List<String> impls = context.getImplementations(ownerFqn);
if (impls != null) { if (receiverContext != null && receiverContext.cic() != null) {
for (String implFqn : impls) { String cicOwnerFqn = resolveCicOwnerFqn(receiverContext);
TypeDeclaration implType = context.getTypeDeclaration(implFqn); if (cicOwnerFqn != null) {
widened.addAll(resolve( TypeDeclaration cicOwnerType = context.getTypeDeclaration(
implFqn, cicOwnerFqn, receiverContext.contextCu());
Optional<AccessorSummary> indexedAccessor = lookupIndexedGetter(cicOwnerFqn, methodName);
ReceiverContext cicReceiverContext = new ReceiverContext(
cicOwnerType,
receiverContext.cic(),
receiverContext.contextCu(),
receiverContext.anonymousGetter(),
receiverContext.fieldValues());
List<String> cicValues = resolveWithCic(
cicOwnerFqn,
methodName, methodName,
new ReceiverContext(implType, null, cicReceiverContext,
receiverContext != null ? receiverContext.contextCu() : null, indexedAccessor.orElse(null),
false, visited);
receiverContext != null ? receiverContext.fieldValues() : null), if (!cicValues.isEmpty()) {
budget, return deduplicatePreservingOrder(cicValues);
visited));
} }
} }
if (!widened.isEmpty()) { return List.of();
return deduplicatePreservingOrder(widened);
} }
if (constantExtractor != null && !budget.isMethodReturnExhausted(0)) {
CompilationUnit contextCu = receiverContext != null ? receiverContext.contextCu() : null;
results.addAll(constantExtractor.resolveMethodReturnConstant(
ownerFqn, methodName, 0, visited, contextCu, budget));
}
if (!results.isEmpty()) {
return deduplicatePreservingOrder(results);
}
return List.of();
} }
Optional<AccessorSummary> indexedAccessor = lookupIndexedGetter(ownerFqn, methodName); Optional<AccessorSummary> indexedAccessor = lookupIndexedGetter(ownerFqn, methodName);
@@ -403,6 +420,28 @@ public final class AccessorResolver {
return accessorOpt.get().fieldName(); return accessorOpt.get().fieldName();
} }
private String resolveCicOwnerFqn(ReceiverContext receiverContext) {
if (receiverContext == null || receiverContext.cic() == null) {
return null;
}
ClassInstanceCreation cic = receiverContext.cic();
CompilationUnit contextCu = receiverContext.contextCu();
String simpleName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType());
TypeDeclaration cicType = contextCu != null
? context.getTypeDeclaration(simpleName, contextCu)
: null;
if (cicType == null) {
cicType = context.getTypeDeclaration(simpleName);
}
if (cicType != null) {
return context.getFqn(cicType);
}
if (cic.resolveTypeBinding() != null) {
return cic.resolveTypeBinding().getErasure().getQualifiedName();
}
return simpleName;
}
private static List<String> deduplicatePreservingOrder(List<String> values) { private static List<String> deduplicatePreservingOrder(List<String> values) {
List<String> deduped = new ArrayList<>(); List<String> deduped = new ArrayList<>();
for (String value : values) { for (String value : values) {

View File

@@ -7,6 +7,8 @@ import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.LambdaExpression; import org.eclipse.jdt.core.dom.LambdaExpression;
import org.eclipse.jdt.core.dom.MethodInvocation; import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.ReturnStatement; import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.ASTNode;
import java.util.Set; import java.util.Set;
@@ -21,14 +23,129 @@ public final class ReactiveExpressionSupport {
} }
public static String extractPayload(Expression expression, ConstantResolver constantResolver, CodebaseContext context) { public static String extractPayload(Expression expression, ConstantResolver constantResolver, CodebaseContext context) {
return extractPayload(expression, null, constantResolver, context);
}
/**
* Rewrites {@code p.getEvent()} inside a {@code flatMap(p -> ...)} lambda to {@code payload.getEvent()}
* when {@code p} is fed by {@code Mono.just(payload)} (or a chained reactive source).
*/
public static String remapLambdaParameterGetter(
Expression eventExpr,
ConstantResolver constantResolver,
CodebaseContext context) {
if (!(eventExpr instanceof MethodInvocation getterCall)) {
return null;
}
if (!(getterCall.getExpression() instanceof SimpleName paramName)) {
return null;
}
LambdaExpression lambda = findEnclosingLambda(getterCall);
if (lambda == null) {
return null;
}
MethodInvocation flatMapCall = findEnclosingFlatMap(lambda);
if (flatMapCall == null) {
return null;
}
String mappedReceiver = mapLambdaParameterToSource(
lambda,
paramName.getIdentifier(),
flatMapCall.getExpression(),
constantResolver,
context);
if (mappedReceiver == null) {
return null;
}
return mappedReceiver + "." + getterCall.getName().getIdentifier() + "()";
}
/**
* Same as {@link #remapLambdaParameterGetter(Expression, ConstantResolver, CodebaseContext)} but locates
* the getter in a real method body when {@code expressionText} came from a detached parse tree.
*/
public static String remapLambdaParameterGetterInMethod(
String expressionText,
String methodFqn,
ConstantResolver constantResolver,
CodebaseContext context) {
MethodInvocation getter = findMethodInvocationInMethod(methodFqn, expressionText, context);
if (getter == null) {
return null;
}
String remapped = remapLambdaParameterGetter(getter, constantResolver, context);
if (remapped != null) {
return remapped;
}
return null;
}
private static MethodInvocation findMethodInvocationInMethod(
String methodFqn,
String expressionText,
CodebaseContext context) {
if (methodFqn == null || expressionText == null || !methodFqn.contains(".")) {
return null;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
return null;
}
org.eclipse.jdt.core.dom.MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null || md.getBody() == null) {
return null;
}
final MethodInvocation[] match = new MethodInvocation[1];
md.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (expressionText.equals(node.toString())) {
match[0] = node;
return false;
}
return super.visit(node);
}
});
return match[0];
}
private static LambdaExpression findEnclosingLambda(ASTNode node) {
ASTNode current = node.getParent();
while (current != null) {
if (current instanceof LambdaExpression lambda) {
return lambda;
}
current = current.getParent();
}
return null;
}
private static MethodInvocation findEnclosingFlatMap(ASTNode node) {
ASTNode current = node.getParent();
while (current != null) {
if (current instanceof MethodInvocation mi && "flatMap".equals(mi.getName().getIdentifier())) {
return mi;
}
current = current.getParent();
}
return null;
}
private static String extractPayload(
Expression expression,
Expression flatMapReceiver,
ConstantResolver constantResolver,
CodebaseContext context) {
if (expression == null) { if (expression == null) {
return null; return null;
} }
if (expression instanceof MethodInvocation mi) { if (expression instanceof MethodInvocation mi) {
String methodName = mi.getName().getIdentifier(); String methodName = mi.getName().getIdentifier();
if ("flatMap".equals(methodName) && !mi.arguments().isEmpty()) { if ("flatMap".equals(methodName) && !mi.arguments().isEmpty()) {
String fromArgument = extractFlatMapArgumentPayload((Expression) mi.arguments().get(0), String fromArgument = extractFlatMapArgumentPayload(
constantResolver, context); (Expression) mi.arguments().get(0), mi.getExpression(), constantResolver, context);
if (fromArgument != null) { if (fromArgument != null) {
return fromArgument; return fromArgument;
} }
@@ -44,7 +161,7 @@ public final class ReactiveExpressionSupport {
return arg.toString(); return arg.toString();
} }
if (mi.getExpression() != null) { if (mi.getExpression() != null) {
return extractPayload(mi.getExpression(), constantResolver, context); return extractPayload(mi.getExpression(), flatMapReceiver, constantResolver, context);
} }
} }
return null; return null;
@@ -52,15 +169,131 @@ public final class ReactiveExpressionSupport {
private static String extractFlatMapArgumentPayload( private static String extractFlatMapArgumentPayload(
Expression argument, Expression argument,
Expression flatMapReceiver,
ConstantResolver constantResolver, ConstantResolver constantResolver,
CodebaseContext context) { CodebaseContext context) {
if (argument instanceof LambdaExpression lambda) { if (argument instanceof LambdaExpression lambda) {
Expression bodyExpression = lambdaBodyExpression(lambda); Expression bodyExpression = lambdaBodyExpression(lambda);
if (bodyExpression != null) { if (bodyExpression != null) {
return extractPayload(bodyExpression, constantResolver, context); String lambdaPayload = extractLambdaBodyPayload(
bodyExpression, lambda, flatMapReceiver, constantResolver, context);
if (lambdaPayload != null) {
return lambdaPayload;
}
return extractPayload(bodyExpression, flatMapReceiver, constantResolver, context);
} }
} }
return extractPayload(argument, constantResolver, context); return extractPayload(argument, flatMapReceiver, constantResolver, context);
}
private static String extractLambdaBodyPayload(
Expression bodyExpression,
LambdaExpression lambda,
Expression flatMapReceiver,
ConstantResolver constantResolver,
CodebaseContext context) {
if (!(bodyExpression instanceof MethodInvocation factoryCall)) {
return null;
}
if (!REACTIVE_FACTORY_METHODS.contains(factoryCall.getName().getIdentifier())
|| factoryCall.arguments().isEmpty()) {
return null;
}
Expression factoryArg = (Expression) factoryCall.arguments().get(0);
if (!(factoryArg instanceof MethodInvocation getterCall)) {
return null;
}
String getterSuffix = "." + getterCall.getName().getIdentifier() + "()";
Expression getterReceiver = getterCall.getExpression();
if (getterReceiver instanceof SimpleName paramName) {
String mappedReceiver = mapLambdaParameterToSource(
lambda, paramName.getIdentifier(), flatMapReceiver, constantResolver, context);
if (mappedReceiver != null) {
return mappedReceiver + getterSuffix;
}
}
if (getterReceiver != null) {
return getterReceiver.toString() + getterSuffix;
}
return null;
}
private static String mapLambdaParameterToSource(
LambdaExpression lambda,
String paramName,
Expression flatMapReceiver,
ConstantResolver constantResolver,
CodebaseContext context) {
if (lambda.parameters().size() != 1) {
return null;
}
Object parameter = lambda.parameters().get(0);
if (!(parameter instanceof org.eclipse.jdt.core.dom.VariableDeclaration variableDeclaration)) {
return null;
}
if (!paramName.equals(variableDeclaration.getName().getIdentifier())) {
return null;
}
if (flatMapReceiver instanceof MethodInvocation receiverFlatMap
&& "flatMap".equals(receiverFlatMap.getName().getIdentifier())
&& !receiverFlatMap.arguments().isEmpty()
&& receiverFlatMap.arguments().get(0) instanceof LambdaExpression feederLambda
&& feederLambda != lambda) {
Expression feederBody = lambdaBodyExpression(feederLambda);
if (feederBody != null) {
String feederPayload = extractLambdaBodyPayload(
feederBody, feederLambda, receiverFlatMap.getExpression(), constantResolver, context);
if (feederPayload != null) {
return feederPayload;
}
String extracted = extractPayload(
feederBody, receiverFlatMap.getExpression(), constantResolver, context);
if (extracted != null) {
return extracted;
}
}
}
Expression source = peelJustArgument(flatMapReceiver);
if (source instanceof MethodInvocation getterMi
&& getterMi.getExpression() instanceof SimpleName innerParamName) {
LambdaExpression outerLambda = findEnclosingLambda(lambda);
if (outerLambda != null && outerLambda != lambda) {
MethodInvocation outerFlatMap = findEnclosingFlatMap(outerLambda);
if (outerFlatMap != null) {
String mappedBase = mapLambdaParameterToSource(
outerLambda,
innerParamName.getIdentifier(),
outerFlatMap.getExpression(),
constantResolver,
context);
if (mappedBase != null) {
return mappedBase + "." + getterMi.getName().getIdentifier() + "()";
}
}
}
}
if (source != null) {
if (constantResolver != null && context != null) {
String resolved = constantResolver.resolve(source, context);
if (resolved != null) {
return resolved;
}
}
return source.toString();
}
return extractPayload(flatMapReceiver, null, constantResolver, context);
}
private static Expression peelJustArgument(Expression expression) {
if (expression instanceof MethodInvocation mi
&& REACTIVE_FACTORY_METHODS.contains(mi.getName().getIdentifier())
&& !mi.arguments().isEmpty()) {
return (Expression) mi.arguments().get(0);
}
if (expression instanceof MethodInvocation mi && mi.getExpression() != null) {
return peelJustArgument(mi.getExpression());
}
return null;
} }
private static Expression lambdaBodyExpression(LambdaExpression lambda) { private static Expression lambdaBodyExpression(LambdaExpression lambda) {

View File

@@ -201,7 +201,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
for (int i = path.size() - 1; i > 0; i--) { for (int i = path.size() - 1; i > 0; i--) {
String target = path.get(i); String target = path.get(i);
String caller = path.get(i - 1); String caller = path.get(i - 1);
int paramIndex = typeResolver.getParameterIndex(target, currentParamName); boolean allowSyntheticPlaceholder = i == path.size() - 1;
int paramIndex = typeResolver.getParameterIndex(target, currentParamName, allowSyntheticPlaceholder);
if (paramIndex < 0) { if (paramIndex < 0) {
if ("this".equals(currentParamName)) { if ("this".equals(currentParamName)) {
String actualReceiver = null; String actualReceiver = null;
@@ -328,7 +329,15 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
String entryMethod = path.get(0); String entryMethod = path.get(0);
if (methodSuffix != null && expressionAccessClassifier.isTraceableAccessorSuffix(methodSuffix)) { if (methodSuffix != null && expressionAccessClassifier.isTraceableAccessorSuffix(methodSuffix)) {
String getterExpression = resolvedValue + methodSuffix;
List<String> chainedGetterEvents = evaluateGetterOnReceiver(resolvedValue, methodSuffix, entryMethod, path, callGraph); List<String> chainedGetterEvents = evaluateGetterOnReceiver(resolvedValue, methodSuffix, entryMethod, path, callGraph);
if (chainedGetterEvents == null || chainedGetterEvents.isEmpty()) {
String remapped = ReactiveExpressionSupport.remapLambdaParameterGetterInMethod(
getterExpression, entryMethod, constantResolver, context);
if (remapped != null) {
chainedGetterEvents = evaluateGetterOnReceiver(remapped, "", entryMethod, path, callGraph);
}
}
if (chainedGetterEvents != null && !chainedGetterEvents.isEmpty()) { if (chainedGetterEvents != null && !chainedGetterEvents.isEmpty()) {
log.debug("Early return (chained getter): getterEvents = {}", chainedGetterEvents); log.debug("Early return (chained getter): getterEvents = {}", chainedGetterEvents);
return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, chainedGetterEvents); return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, chainedGetterEvents);
@@ -421,6 +430,19 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
} }
if (exprNode instanceof Expression expressionNode) { if (exprNode instanceof Expression expressionNode) {
String remappedGetter = ReactiveExpressionSupport.remapLambdaParameterGetter(
expressionNode, constantResolver, context);
if (remappedGetter == null) {
remappedGetter = ReactiveExpressionSupport.remapLambdaParameterGetterInMethod(
resolvedValue, entryMethod, constantResolver, context);
}
if (remappedGetter != null) {
List<String> remappedEvents = evaluateGetterOnReceiver(
remappedGetter, "", entryMethod, path, callGraph);
if (remappedEvents != null && !remappedEvents.isEmpty()) {
return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, remappedEvents);
}
}
String reactivePayload = ReactiveExpressionSupport.extractPayload( String reactivePayload = ReactiveExpressionSupport.extractPayload(
expressionNode, constantResolver, context); expressionNode, constantResolver, context);
if (reactivePayload != null) { if (reactivePayload != null) {
@@ -896,7 +918,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}); });
String targetMethod = path.get(path.size() - 1); String targetMethod = path.get(path.size() - 1);
int eventParamIndex = typeResolver.getParameterIndex(targetMethod, event); int eventParamIndex = typeResolver.getParameterIndex(targetMethod, event, true);
if (eventParamIndex < 0) { if (eventParamIndex < 0) {
eventParamIndex = 0; eventParamIndex = 0;
} }
@@ -965,6 +987,29 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
.build(); .build();
} }
if (polymorphicEvents.isEmpty()) {
String[] extracted = extractMethodSuffix(resolvedValue, "", entryMethod);
if (expressionAccessClassifier.isTraceableAccessorSuffix(extracted[1])) {
String getterExpression = extracted[0] + extracted[1];
List<String> getterEvents = evaluateGetterOnReceiver(
extracted[0], extracted[1], entryMethod, path, callGraph);
if (getterEvents == null || getterEvents.isEmpty()) {
String remapped = ReactiveExpressionSupport.remapLambdaParameterGetterInMethod(
getterExpression, entryMethod, constantResolver, context);
if (remapped != null) {
getterEvents = evaluateGetterOnReceiver(remapped, "", entryMethod, path, callGraph);
}
}
if (getterEvents != null && !getterEvents.isEmpty()) {
return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, getterEvents);
}
}
if (exprNode instanceof ClassInstanceCreation cic) {
polymorphicEvents.add(click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType()));
return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, polymorphicEvents);
}
}
return tp; return tp;
} finally { } finally {
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.clearCurrentPath(); click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.clearCurrentPath();
@@ -1326,7 +1371,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
} }
} else if (exprNode instanceof MethodInvocation mi) { } else if (exprNode instanceof MethodInvocation mi) {
String getterName = mi.getName().getIdentifier(); String getterName = mi.getName().getIdentifier();
String propName = methodSuffix.startsWith(".get") ? methodSuffix.substring(4) : methodSuffix.substring(1); String propName = methodSuffix != null && !methodSuffix.isEmpty()
? (methodSuffix.startsWith(".get") ? methodSuffix.substring(4) : methodSuffix.substring(1))
: getterName;
Expression receiver = mi.getExpression(); Expression receiver = mi.getExpression();
String receiverName = null; String receiverName = null;
String receiverType = null; String receiverType = null;
@@ -1379,6 +1426,18 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
receiverType = variableTracer.getVariableDeclaredType(callerMethod, receiverName); receiverType = variableTracer.getVariableDeclaredType(callerMethod, receiverName);
} }
if (receiverType == null && receiver instanceof SimpleName) {
String remapped = ReactiveExpressionSupport.remapLambdaParameterGetter(
mi, constantResolver, context);
if (remapped == null) {
remapped = ReactiveExpressionSupport.remapLambdaParameterGetterInMethod(
mi.toString(), scopeMethod, constantResolver, context);
}
if (remapped != null) {
return evaluateGetterOnReceiver(remapped, "", entryMethod, path, callGraph);
}
}
if (receiverType != null) { if (receiverType != null) {
String simpleReceiverType = receiverType; String simpleReceiverType = receiverType;
if (receiverType.contains("<")) { if (receiverType.contains("<")) {

View File

@@ -447,6 +447,19 @@ public class ConstantExtractor {
} }
} }
} }
} else if (widenToImplementations) {
List<String> impls = context.getImplementations(className);
if (impls != null) {
for (String implName : impls) {
List<String> delegationResult = resolveMethodReturnConstant(
implName, methodName, depth + 1, visited, contextCu, activeBudget);
for (String value : delegationResult) {
if (!constants.contains(value)) {
constants.add(value);
}
}
}
}
} }
visited.remove(fqn); visited.remove(fqn);
log.debug("resolveMethodReturnConstant className={} methodName={} returns: {}", className, methodName, constants); log.debug("resolveMethodReturnConstant className={} methodName={} returns: {}", className, methodName, constants);

View File

@@ -238,6 +238,11 @@ public class GenericEventDetector {
} else { } else {
eventValue = context.resolveExpression(eventExpr); eventValue = context.resolveExpression(eventExpr);
} }
String remapped = ReactiveExpressionSupport.remapLambdaParameterGetter(
eventExpr, constantResolver, context);
if (remapped != null) {
eventValue = remapped;
}
} }
MethodDeclaration method = findEnclosingMethod(node); MethodDeclaration method = findEnclosingMethod(node);

View File

@@ -7,11 +7,21 @@ import java.util.*;
public class TypeResolver { public class TypeResolver {
private final CodebaseContext context; private final CodebaseContext context;
private static final Set<String> GENERIC_EVENT_PARAMETER_NAMES = Set.of("e", "ev");
public TypeResolver(CodebaseContext context) { public TypeResolver(CodebaseContext context) {
this.context = context; this.context = context;
} }
public int getParameterIndex(String methodFqn, String paramName) { public int getParameterIndex(String methodFqn, String paramName) {
return getParameterIndex(methodFqn, paramName, false);
}
/**
* When {@code allowSyntheticEventPlaceholder} is true, maps generic trigger placeholders
* such as {@code e} onto the sole parameter of single-arg methods (e.g. {@code sendEvent}).
*/
public int getParameterIndex(String methodFqn, String paramName, boolean allowSyntheticEventPlaceholder) {
if (methodFqn == null || !methodFqn.contains(".")) return -1; if (methodFqn == null || !methodFqn.contains(".")) return -1;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
@@ -25,11 +35,20 @@ public class TypeResolver {
return i; return i;
} }
} }
if (allowSyntheticEventPlaceholder
&& md.parameters().size() == 1
&& isGenericEventParameterName(paramName)) {
return 0;
}
} }
} }
return -1; return -1;
} }
private static boolean isGenericEventParameterName(String paramName) {
return paramName != null && GENERIC_EVENT_PARAMETER_NAMES.contains(paramName);
}
public List<String> getParameterNames(String methodFqn) { public List<String> getParameterNames(String methodFqn) {
if (methodFqn == null || !methodFqn.contains(".")) { if (methodFqn == null || !methodFqn.contains(".")) {
return null; return null;

View File

@@ -337,19 +337,6 @@ public class VariableTracer {
if (fieldType != null) return fieldType; if (fieldType != null) return fieldType;
} }
} }
// Final fallback: ask constantExtractor to resolve it via method return constant
if (constantExtractor != null && methodFqn.contains(".")) {
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
CompilationUnit cu = td != null && td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
List<String> values = constantExtractor.resolveMethodReturnConstant(cleanFieldName, methodName, 0, new HashSet<>(), cu);
if (values != null && !values.isEmpty()) {
// If it resolves to some class constant, we could infer type or return a fallback
return null;
}
}
return null; return null;
} }

View File

@@ -20,6 +20,7 @@ import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -346,9 +347,11 @@ public class CodebaseContext {
if (interfaceName != null && interfaceName.contains("<")) { if (interfaceName != null && interfaceName.contains("<")) {
cleanName = interfaceName.substring(0, interfaceName.indexOf('<')); cleanName = interfaceName.substring(0, interfaceName.indexOf('<'));
} }
Set<String> allImpls = new HashSet<>(); Set<String> allImpls = new LinkedHashSet<>();
collectImplementations(cleanName, allImpls, new HashSet<>()); collectImplementations(cleanName, allImpls, new HashSet<>());
return new ArrayList<>(allImpls); List<String> sorted = new ArrayList<>(allImpls);
Collections.sort(sorted);
return sorted;
} }
private void collectImplementations(String typeName, Set<String> results, Set<String> visited) { private void collectImplementations(String typeName, Set<String> results, Set<String> visited) {

View File

@@ -372,6 +372,11 @@ public class JdtDataFlowModel implements DataFlowModel {
if (concreteAccessor.isPresent()) { if (concreteAccessor.isPresent()) {
accessor = concreteAccessor; accessor = concreteAccessor;
} else if (shouldSkipAccessorDueToConcreteOverride(declaringFqn, methodName, receiverDefs)) { } else if (shouldSkipAccessorDueToConcreteOverride(declaringFqn, methodName, receiverDefs)) {
List<Expression> concreteOverride = inlineConcreteMethodOverride(
methodName, receiverDefs, visited, paramBindings, instanceFieldBindings, depth);
if (!concreteOverride.isEmpty()) {
return concreteOverride;
}
return List.of(); return List.of();
} }
} }
@@ -457,6 +462,42 @@ public class JdtDataFlowModel implements DataFlowModel {
return false; return false;
} }
private List<Expression> inlineConcreteMethodOverride(
String methodName,
List<Expression> receiverDefs,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
List<Expression> results = new ArrayList<>();
for (Expression receiverDef : receiverDefs) {
String concreteFqn = concreteReceiverTypeFqn(receiverDef);
if (concreteFqn == null) {
continue;
}
TypeDeclaration concreteType = context.getTypeDeclaration(concreteFqn);
if (concreteType == null) {
continue;
}
MethodDeclaration method = context.findMethodDeclaration(concreteType, methodName, true);
if (method == null || method.getBody() == null) {
continue;
}
for (Object statement : method.getBody().statements()) {
if (statement instanceof ReturnStatement returnStatement
&& returnStatement.getExpression() != null) {
results.addAll(getReachingDefinitions(
returnStatement.getExpression(),
visited,
paramBindings,
instanceFieldBindings,
depth + 1));
}
}
}
return results;
}
private String concreteReceiverTypeFqn(Expression receiverDef) { private String concreteReceiverTypeFqn(Expression receiverDef) {
if (receiverDef instanceof ClassInstanceCreation cic) { if (receiverDef instanceof ClassInstanceCreation cic) {
return AccessorInlining.resolveTypeFqn(cic); return AccessorInlining.resolveTypeFqn(cic);

View File

@@ -0,0 +1,607 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
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.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
import click.kamil.springstatemachineexporter.analysis.service.HeuristicCallGraphEngine;
import click.kamil.springstatemachineexporter.analysis.service.VariableTracer;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.common.DataFlowModel;
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
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.HashSet;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class InheritanceAndNestedResolutionTest {
@TempDir
Path tempDir;
CodebaseContext context;
ConstantExtractor constantExtractor;
ConstructorAnalyzer constructorAnalyzer;
AccessorResolver accessorResolver;
VariableTracer variableTracer;
@BeforeEach
void setUp() throws IOException {
context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(tempDir.toAbsolutePath().toString()));
ConstantResolver constantResolver = context.getConstantResolver();
constantExtractor = new ConstantExtractor(context, constantResolver, mi -> null);
constructorAnalyzer = new ConstructorAnalyzer(context, constantResolver);
variableTracer = new VariableTracer(context, constantResolver);
constantExtractor.setVariableTracer(variableTracer);
constantExtractor.setConstructorAnalyzer(constructorAnalyzer);
constructorAnalyzer.setVariableTracer(variableTracer);
constructorAnalyzer.setConstantExtractor(constantExtractor);
accessorResolver = new AccessorResolver(context, constantExtractor, constructorAnalyzer, constantResolver);
constantExtractor.setAccessorResolver(accessorResolver);
}
private void writeJava(String relativePath, String source) throws IOException {
Path file = tempDir.resolve(relativePath);
Files.createDirectories(file.getParent());
Files.writeString(file, source);
}
private void scan() throws IOException {
context.scan(tempDir);
}
@Nested
class InterfaceDefaultMethod {
@Test
void accessorResolverShouldIncludeDefaultMethodAndAllImpls() throws IOException {
writeJava("com/example/Code.java", """
package com.example;
public enum Code { DEFAULT, PAY, SHIP }
""");
writeJava("com/example/EventSource.java", """
package com.example;
public interface EventSource {
default Code getCode() { return Code.DEFAULT; }
}
""");
writeJava("com/example/PayEvent.java", """
package com.example;
public class PayEvent implements EventSource {
public Code getCode() { return Code.PAY; }
}
""");
writeJava("com/example/ShipEvent.java", """
package com.example;
public class ShipEvent implements EventSource {
public Code getCode() { return Code.SHIP; }
}
""");
scan();
List<String> resolved = accessorResolver.resolve(
"com.example.EventSource",
"getCode",
new AccessorResolver.ReceiverContext(
context.getTypeDeclaration("com.example.EventSource"),
null, null, false, null),
ResolutionBudget.defaults());
assertThat(resolved).containsExactlyInAnyOrder("Code.DEFAULT", "Code.PAY", "Code.SHIP");
}
@Test
void accessorResolverShouldMergeDefaultMethodWithConcreteOverrides() throws IOException {
writeJava("com/example/EventSource.java", """
package com.example;
public interface EventSource {
default String getCode() { return "DEFAULT"; }
}
""");
writeJava("com/example/PayEvent.java", """
package com.example;
public class PayEvent implements EventSource {
public String getCode() { return "PAY"; }
}
""");
writeJava("com/example/ShipEvent.java", """
package com.example;
public class ShipEvent implements EventSource {
public String getCode() { return "SHIP"; }
}
""");
scan();
List<String> resolved = accessorResolver.resolve(
"com.example.EventSource",
"getCode",
new AccessorResolver.ReceiverContext(
context.getTypeDeclaration("com.example.EventSource"),
null, null, false, null),
ResolutionBudget.defaults());
assertThat(resolved).containsExactlyInAnyOrder("DEFAULT", "PAY", "SHIP");
}
@Test
void accessorResolverShouldPreferConcreteCicOverInterfaceDefault() throws IOException {
writeJava("com/example/Code.java", """
package com.example;
public enum Code { DEFAULT, PAY, SHIP }
""");
writeJava("com/example/EventSource.java", """
package com.example;
public interface EventSource {
default Code getCode() { return Code.DEFAULT; }
}
""");
writeJava("com/example/PayEvent.java", """
package com.example;
public class PayEvent implements EventSource {
public Code getCode() { return Code.PAY; }
}
""");
writeJava("com/example/Runner.java", """
package com.example;
public class Runner {
void run() {
EventSource src = new PayEvent();
}
}
""");
scan();
org.eclipse.jdt.core.dom.TypeDeclaration runner = context.getTypeDeclaration("com.example.Runner");
org.eclipse.jdt.core.dom.CompilationUnit cu = (org.eclipse.jdt.core.dom.CompilationUnit) runner.getRoot();
final org.eclipse.jdt.core.dom.ClassInstanceCreation[] payEventCic =
new org.eclipse.jdt.core.dom.ClassInstanceCreation[1];
cu.accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(org.eclipse.jdt.core.dom.ClassInstanceCreation node) {
if ("PayEvent".equals(node.getType().toString())) {
payEventCic[0] = node;
}
return super.visit(node);
}
});
List<String> resolved = accessorResolver.resolve(
"com.example.EventSource",
"getCode",
new AccessorResolver.ReceiverContext(
context.getTypeDeclaration("com.example.EventSource"),
payEventCic[0],
cu,
false,
null),
ResolutionBudget.defaults());
assertThat(resolved).containsExactly("Code.PAY");
}
}
@Nested
class NonIndexedConcreteOverride {
@Test
void dataFlowShouldResolveNonTrivialConcreteOverride() throws IOException {
writeJava("com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
Payload payload = new CustomPayload();
String event = payload.getEvent();
}
}
interface Payload {
String getEvent();
}
class CustomPayload implements Payload {
public String getEvent() {
return sideEffect();
}
private String sideEffect() { return "CUSTOM"; }
}
""");
scan();
TypeDeclaration runner = context.getTypeDeclaration("com.example.Runner");
MethodDeclaration run = context.findMethodDeclaration(runner, "run", true);
final MethodInvocation[] getterCall = new MethodInvocation[1];
run.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getInitializer() instanceof MethodInvocation mi) {
getterCall[0] = mi;
}
return super.visit(node);
}
});
DataFlowModel model = new JdtDataFlowModel(context);
String resolved = model.resolveValue(getterCall[0], context);
assertThat(resolved).isEqualTo("CUSTOM");
}
}
@Nested
class GetterChainThroughInterface {
@Test
void callGraphShouldResolveDeepChainWithInterfaceMiddleHop() throws IOException {
writeJava("com/example/Api.java", """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
Outer outer;
public void pay() {
dispatcher.fire(outer.getInner().getPayload().getEvent());
}
}
class CentralDispatcher {
public void fire(OrderEvent event) {}
}
interface Inner {
Payload getPayload();
}
class InnerImpl implements Inner {
private Payload payload = new Payload();
public Payload getPayload() { return payload; }
}
class Outer {
public Inner getInner() { return new InnerImpl(); }
}
class Payload {
private OrderEvent event = OrderEvent.PAY;
public OrderEvent getEvent() { return event; }
}
enum OrderEvent { PAY, SHIP }
class OrderStateMachineConfig {}
""");
scan();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entry = EntryPoint.builder()
.className("com.example.ApiController")
.methodName("pay")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.CentralDispatcher")
.methodName("fire")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.contains("OrderEvent.PAY");
}
}
@Nested
class ConcreteSubtypeTrigger {
@Test
void callGraphShouldPreferConcreteSubtypeFieldOverAbstractDefault() throws IOException {
writeJava("com/example/Api.java", """
package com.example;
public class ApiController {
OrderService service;
public void ship() {
service.fire(new ShipEvent());
}
}
class OrderService {
StateMachine sm;
public void fire(BaseEvent event) {
sm.sendEvent(event.getCode());
}
}
enum Code { PAY, SHIP }
abstract class BaseEvent {
protected Code code = Code.PAY;
public Code getCode() { return code; }
}
class ShipEvent extends BaseEvent {
public ShipEvent() { this.code = Code.SHIP; }
}
class StateMachine { void sendEvent(Code event) {} }
class OrderStateMachineConfig {}
""");
scan();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entry = EntryPoint.builder()
.className("com.example.ApiController")
.methodName("ship")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactly("Code.SHIP");
}
}
@Nested
class ConcreteSubtypeTriggerViaFire {
@Test
void callGraphShouldResolveSubtypeWhenPassedToBaseTypedFire() throws IOException {
writeJava("com/example/Api.java", """
package com.example;
public class ApiController {
OrderService service;
public void ship() {
service.fire(new ShipEvent());
}
}
class OrderService {
public void fire(BaseEvent event) {}
}
enum Code { PAY, SHIP }
abstract class BaseEvent {
protected Code code = Code.PAY;
public Code getCode() { return code; }
}
class ShipEvent extends BaseEvent {
public ShipEvent() { this.code = Code.SHIP; }
}
class OrderStateMachineConfig {}
""");
scan();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entry = EntryPoint.builder()
.className("com.example.ApiController")
.methodName("ship")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.OrderService")
.methodName("fire")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getMethodChain())
.containsExactly(
"com.example.ApiController.ship",
"com.example.OrderService.fire");
assertThat(chains.get(0).getTriggerPoint().getEvent())
.contains("ShipEvent");
}
}
@Nested
class UnresolvedTypeWidening {
@Test
void constantExtractorShouldWidenWhenTypeDeclarationMissing() throws IOException {
writeJava("com/example/Code.java", """
package com.example;
public enum Code { A, B }
""");
writeJava("com/example/EventA.java", """
package com.example;
public class EventA implements RichEvent {
public Code getCode() { return Code.A; }
}
""");
writeJava("com/example/EventB.java", """
package com.example;
public class EventB implements RichEvent {
public Code getCode() { return Code.B; }
}
""");
writeJava("com/example/RichEvent.java", """
package com.example;
public interface RichEvent {
Code getCode();
}
""");
scan();
List<String> resolved = constantExtractor.resolveMethodReturnConstant(
"RichEvent", "getCode", 0, new HashSet<>(), null);
assertThat(resolved).containsExactlyInAnyOrder("Code.A", "Code.B");
}
}
@Nested
class DeterministicImplementations {
@Test
void getImplementationsShouldReturnSortedStableOrder() throws IOException {
writeJava("com/example/EventSource.java", """
package com.example;
public interface EventSource {
String getCode();
}
""");
writeJava("com/example/ZebraEvent.java", """
package com.example;
public class ZebraEvent implements EventSource {
public String getCode() { return "Z"; }
}
""");
writeJava("com/example/AlphaEvent.java", """
package com.example;
public class AlphaEvent implements EventSource {
public String getCode() { return "A"; }
}
""");
scan();
List<String> first = context.getImplementations("com.example.EventSource");
List<String> second = context.getImplementations("com.example.EventSource");
assertThat(first).containsExactly(
"com.example.AlphaEvent",
"com.example.ZebraEvent");
assertThat(second).isEqualTo(first);
}
}
@Nested
class ReactiveFlatMapLambdaSendEvent {
@Test
void callGraphShouldResolveGetterInsideFlatMapLambda() throws IOException {
writeJava("com/example/Api.java", """
package com.example;
public class ApiController {
StateMachine sm;
Payload payload;
public void handle() {
Mono.just(payload).flatMap(p -> sm.sendEvent(p.getEvent()));
}
}
class Payload {
OrderEvent event = OrderEvent.PAY;
OrderEvent getEvent() { return event; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent event) {} }
class Mono {
static Mono just(Object o) { return new Mono(); }
Mono flatMap(Object fn) { return this; }
}
class OrderStateMachineConfig {}
""");
scan();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entry = EntryPoint.builder()
.className("com.example.ApiController")
.methodName("handle")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactly("OrderEvent.PAY");
}
@Test
void callGraphShouldResolveGetterInsideFlatMapBlockBody() throws IOException {
writeJava("com/example/Api.java", """
package com.example;
public class ApiController {
StateMachine sm;
Payload payload;
public void handle() {
Mono.just(payload).flatMap(p -> {
sm.sendEvent(p.getEvent());
return Mono.empty();
});
}
}
class Payload {
OrderEvent event = OrderEvent.PAY;
OrderEvent getEvent() { return event; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent event) {} }
class Mono {
static Mono just(Object o) { return new Mono(); }
Mono flatMap(Object fn) { return this; }
static Mono empty() { return new Mono(); }
}
class OrderStateMachineConfig {}
""");
scan();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entry = EntryPoint.builder()
.className("com.example.ApiController")
.methodName("handle")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactly("OrderEvent.PAY");
}
@Test
void callGraphShouldResolveGetterThroughNestedFlatMap() throws IOException {
writeJava("com/example/Api.java", """
package com.example;
public class ApiController {
StateMachine sm;
Payload payload;
public void handle() {
Mono.just(payload)
.flatMap(x -> Mono.just(x.getInner()))
.flatMap(p -> sm.sendEvent(p.getEvent()));
}
}
class Payload {
Inner inner = new Inner();
Inner getInner() { return inner; }
}
class Inner {
OrderEvent event = OrderEvent.SHIP;
OrderEvent getEvent() { return event; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { void sendEvent(OrderEvent event) {} }
class Mono {
static Mono just(Object o) { return new Mono(); }
Mono flatMap(Object fn) { return this; }
}
class OrderStateMachineConfig {}
""");
scan();
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entry = EntryPoint.builder()
.className("com.example.ApiController")
.methodName("handle")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactly("OrderEvent.SHIP");
}
}
}

View File

@@ -0,0 +1,99 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class ReactiveExpressionSupportTest {
@Test
void shouldMapLambdaParameterToOuterJustPayload(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class Runner {
Payload payload;
void run() {
Mono.just(payload).flatMap(p -> Mono.just(p.getEvent()));
}
}
class Payload { String getEvent() { return "X"; } }
class Mono {
static Mono just(Object o) { return new Mono(); }
Mono flatMap(Object fn) { return this; }
}
""";
Path file = tempDir.resolve("Runner.java");
Files.writeString(file, source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
org.eclipse.jdt.core.dom.TypeDeclaration runner = context.getTypeDeclaration("com.example.Runner");
CompilationUnit cu = (CompilationUnit) runner.getRoot();
final MethodInvocation[] flatMapCall = new MethodInvocation[1];
cu.accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if ("flatMap".equals(node.getName().getIdentifier()) && flatMapCall[0] == null) {
flatMapCall[0] = node;
}
return super.visit(node);
}
});
String payload = ReactiveExpressionSupport.extractPayload(
flatMapCall[0], context.getConstantResolver(), context);
assertThat(payload).isEqualTo("payload.getEvent()");
}
@Test
void shouldRemapLambdaParameterGetter(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class Runner {
Payload payload;
StateMachine sm;
void run() {
Mono.just(payload).flatMap(p -> sm.sendEvent(p.getEvent()));
}
}
class Payload { String getEvent() { return "X"; } }
class StateMachine { void sendEvent(String e) {} }
class Mono {
static Mono just(Object o) { return new Mono(); }
Mono flatMap(Object fn) { return this; }
}
""";
Path file = tempDir.resolve("Runner.java");
Files.writeString(file, source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
org.eclipse.jdt.core.dom.TypeDeclaration runner = context.getTypeDeclaration("com.example.Runner");
CompilationUnit cu = (CompilationUnit) runner.getRoot();
final MethodInvocation[] sendEventCall = new MethodInvocation[1];
cu.accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if ("sendEvent".equals(node.getName().getIdentifier()) && sendEventCall[0] == null) {
sendEventCall[0] = node;
}
return super.visit(node);
}
});
Expression eventExpr = (Expression) sendEventCall[0].arguments().get(0);
String remapped = ReactiveExpressionSupport.remapLambdaParameterGetter(
eventExpr, context.getConstantResolver(), context);
assertThat(remapped).isEqualTo("payload.getEvent()");
}
}

View File

@@ -0,0 +1,33 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class TypeResolverTest {
@Test
void shouldMapGenericEventPlaceholderToSingleParameterIndex(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("Machine.java"), """
package com.example;
class StateMachine {
void sendEvent(OrderEvent event) {}
}
enum OrderEvent { PAY }
""");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeResolver resolver = new TypeResolver(context);
assertThat(resolver.getParameterIndex("com.example.StateMachine.sendEvent", "event")).isZero();
assertThat(resolver.getParameterIndex("com.example.StateMachine.sendEvent", "e", true)).isZero();
assertThat(resolver.getParameterIndex("com.example.StateMachine.sendEvent", "e")).isEqualTo(-1);
assertThat(resolver.getParameterIndex("com.example.StateMachine.sendEvent", "payload")).isEqualTo(-1);
}
}