From cc35546d01dbf7b869306be0ec9ff01840f1ab02 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Sun, 12 Jul 2026 07:06:55 +0200 Subject: [PATCH] Fix CIC accessor resolution, concrete overrides, and reactive flatMap tracing. Co-authored-by: Cursor --- .../analysis/pipeline/AccessorResolver.java | 69 +- .../pipeline/ReactiveExpressionSupport.java | 243 ++++++- .../service/AbstractCallGraphEngine.java | 65 +- .../analysis/service/ConstantExtractor.java | 13 + .../service/GenericEventDetector.java | 5 + .../analysis/service/TypeResolver.java | 19 + .../analysis/service/VariableTracer.java | 13 - .../ast/common/CodebaseContext.java | 7 +- .../ast/common/JdtDataFlowModel.java | 41 ++ .../InheritanceAndNestedResolutionTest.java | 607 ++++++++++++++++++ .../ReactiveExpressionSupportTest.java | 99 +++ .../analysis/service/TypeResolverTest.java | 33 + 12 files changed, 1176 insertions(+), 38 deletions(-) create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/InheritanceAndNestedResolutionTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/ReactiveExpressionSupportTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/TypeResolverTest.java diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/AccessorResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/AccessorResolver.java index 1920c65..aacb25d 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/AccessorResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/AccessorResolver.java @@ -184,25 +184,42 @@ public final class AccessorResolver { || org.eclipse.jdt.core.dom.Modifier.isAbstract(ownerType.getModifiers()); if (widenToImplementations) { - List widened = new ArrayList<>(); - List impls = context.getImplementations(ownerFqn); - if (impls != null) { - for (String implFqn : impls) { - TypeDeclaration implType = context.getTypeDeclaration(implFqn); - widened.addAll(resolve( - implFqn, + List results = new ArrayList<>(); + + if (receiverContext != null && receiverContext.cic() != null) { + String cicOwnerFqn = resolveCicOwnerFqn(receiverContext); + if (cicOwnerFqn != null) { + TypeDeclaration cicOwnerType = context.getTypeDeclaration( + cicOwnerFqn, receiverContext.contextCu()); + Optional indexedAccessor = lookupIndexedGetter(cicOwnerFqn, methodName); + ReceiverContext cicReceiverContext = new ReceiverContext( + cicOwnerType, + receiverContext.cic(), + receiverContext.contextCu(), + receiverContext.anonymousGetter(), + receiverContext.fieldValues()); + List cicValues = resolveWithCic( + cicOwnerFqn, methodName, - new ReceiverContext(implType, null, - receiverContext != null ? receiverContext.contextCu() : null, - false, - receiverContext != null ? receiverContext.fieldValues() : null), - budget, - visited)); + cicReceiverContext, + indexedAccessor.orElse(null), + visited); + if (!cicValues.isEmpty()) { + return deduplicatePreservingOrder(cicValues); + } } + return List.of(); } - if (!widened.isEmpty()) { - 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 indexedAccessor = lookupIndexedGetter(ownerFqn, methodName); @@ -403,6 +420,28 @@ public final class AccessorResolver { 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 deduplicatePreservingOrder(List values) { List deduped = new ArrayList<>(); for (String value : values) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/ReactiveExpressionSupport.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/ReactiveExpressionSupport.java index c0702df..822ca0d 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/ReactiveExpressionSupport.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/ReactiveExpressionSupport.java @@ -7,6 +7,8 @@ import org.eclipse.jdt.core.dom.Expression; import org.eclipse.jdt.core.dom.LambdaExpression; import org.eclipse.jdt.core.dom.MethodInvocation; 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; @@ -21,14 +23,129 @@ public final class ReactiveExpressionSupport { } 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) { return null; } if (expression instanceof MethodInvocation mi) { String methodName = mi.getName().getIdentifier(); if ("flatMap".equals(methodName) && !mi.arguments().isEmpty()) { - String fromArgument = extractFlatMapArgumentPayload((Expression) mi.arguments().get(0), - constantResolver, context); + String fromArgument = extractFlatMapArgumentPayload( + (Expression) mi.arguments().get(0), mi.getExpression(), constantResolver, context); if (fromArgument != null) { return fromArgument; } @@ -44,7 +161,7 @@ public final class ReactiveExpressionSupport { return arg.toString(); } if (mi.getExpression() != null) { - return extractPayload(mi.getExpression(), constantResolver, context); + return extractPayload(mi.getExpression(), flatMapReceiver, constantResolver, context); } } return null; @@ -52,15 +169,131 @@ public final class ReactiveExpressionSupport { private static String extractFlatMapArgumentPayload( Expression argument, + Expression flatMapReceiver, ConstantResolver constantResolver, CodebaseContext context) { if (argument instanceof LambdaExpression lambda) { Expression bodyExpression = lambdaBodyExpression(lambda); 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) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java index 8468fb1..79a8e78 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java @@ -201,7 +201,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { for (int i = path.size() - 1; i > 0; i--) { String target = path.get(i); 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 ("this".equals(currentParamName)) { String actualReceiver = null; @@ -328,7 +329,15 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { String entryMethod = path.get(0); if (methodSuffix != null && expressionAccessClassifier.isTraceableAccessorSuffix(methodSuffix)) { + String getterExpression = resolvedValue + methodSuffix; List 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()) { log.debug("Early return (chained getter): getterEvents = {}", chainedGetterEvents); return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, chainedGetterEvents); @@ -421,6 +430,19 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } 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 remappedEvents = evaluateGetterOnReceiver( + remappedGetter, "", entryMethod, path, callGraph); + if (remappedEvents != null && !remappedEvents.isEmpty()) { + return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, remappedEvents); + } + } String reactivePayload = ReactiveExpressionSupport.extractPayload( expressionNode, constantResolver, context); if (reactivePayload != null) { @@ -896,7 +918,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { }); String targetMethod = path.get(path.size() - 1); - int eventParamIndex = typeResolver.getParameterIndex(targetMethod, event); + int eventParamIndex = typeResolver.getParameterIndex(targetMethod, event, true); if (eventParamIndex < 0) { eventParamIndex = 0; } @@ -964,6 +986,29 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { .external(tp.isExternal()) .build(); } + + if (polymorphicEvents.isEmpty()) { + String[] extracted = extractMethodSuffix(resolvedValue, "", entryMethod); + if (expressionAccessClassifier.isTraceableAccessorSuffix(extracted[1])) { + String getterExpression = extracted[0] + extracted[1]; + List 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; } finally { @@ -1326,7 +1371,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } } else if (exprNode instanceof MethodInvocation mi) { 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(); String receiverName = null; String receiverType = null; @@ -1379,6 +1426,18 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { 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) { String simpleReceiverType = receiverType; if (receiverType.contains("<")) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java index 83a5669..b1b663e 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java @@ -447,6 +447,19 @@ public class ConstantExtractor { } } } + } else if (widenToImplementations) { + List impls = context.getImplementations(className); + if (impls != null) { + for (String implName : impls) { + List delegationResult = resolveMethodReturnConstant( + implName, methodName, depth + 1, visited, contextCu, activeBudget); + for (String value : delegationResult) { + if (!constants.contains(value)) { + constants.add(value); + } + } + } + } } visited.remove(fqn); log.debug("resolveMethodReturnConstant className={} methodName={} returns: {}", className, methodName, constants); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java index cef8ed2..f78eb2d 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java @@ -238,6 +238,11 @@ public class GenericEventDetector { } else { eventValue = context.resolveExpression(eventExpr); } + String remapped = ReactiveExpressionSupport.remapLambdaParameterGetter( + eventExpr, constantResolver, context); + if (remapped != null) { + eventValue = remapped; + } } MethodDeclaration method = findEnclosingMethod(node); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/TypeResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/TypeResolver.java index e1c3693..ec2d1a0 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/TypeResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/TypeResolver.java @@ -7,11 +7,21 @@ import java.util.*; public class TypeResolver { private final CodebaseContext context; + private static final Set GENERIC_EVENT_PARAMETER_NAMES = Set.of("e", "ev"); + public TypeResolver(CodebaseContext context) { this.context = context; } 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; String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); @@ -25,11 +35,20 @@ public class TypeResolver { return i; } } + if (allowSyntheticEventPlaceholder + && md.parameters().size() == 1 + && isGenericEventParameterName(paramName)) { + return 0; + } } } return -1; } + private static boolean isGenericEventParameterName(String paramName) { + return paramName != null && GENERIC_EVENT_PARAMETER_NAMES.contains(paramName); + } + public List getParameterNames(String methodFqn) { if (methodFqn == null || !methodFqn.contains(".")) { return null; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java index 5648e4d..73ccfdf 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java @@ -336,19 +336,6 @@ public class VariableTracer { String fieldType = getFieldType(td, cleanFieldName, context, new java.util.HashSet<>()); 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 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; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java index 43b7f48..ac9ac17 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java @@ -20,6 +20,7 @@ import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -346,9 +347,11 @@ public class CodebaseContext { if (interfaceName != null && interfaceName.contains("<")) { cleanName = interfaceName.substring(0, interfaceName.indexOf('<')); } - Set allImpls = new HashSet<>(); + Set allImpls = new LinkedHashSet<>(); collectImplementations(cleanName, allImpls, new HashSet<>()); - return new ArrayList<>(allImpls); + List sorted = new ArrayList<>(allImpls); + Collections.sort(sorted); + return sorted; } private void collectImplementations(String typeName, Set results, Set visited) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java index f4a1bf5..35538bd 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java @@ -372,6 +372,11 @@ public class JdtDataFlowModel implements DataFlowModel { if (concreteAccessor.isPresent()) { accessor = concreteAccessor; } else if (shouldSkipAccessorDueToConcreteOverride(declaringFqn, methodName, receiverDefs)) { + List concreteOverride = inlineConcreteMethodOverride( + methodName, receiverDefs, visited, paramBindings, instanceFieldBindings, depth); + if (!concreteOverride.isEmpty()) { + return concreteOverride; + } return List.of(); } } @@ -457,6 +462,42 @@ public class JdtDataFlowModel implements DataFlowModel { return false; } + private List inlineConcreteMethodOverride( + String methodName, + List receiverDefs, + Set visited, + Map paramBindings, + Map instanceFieldBindings, + int depth) { + List 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) { if (receiverDef instanceof ClassInstanceCreation cic) { return AccessorInlining.resolveTypeFqn(cic); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/InheritanceAndNestedResolutionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/InheritanceAndNestedResolutionTest.java new file mode 100644 index 0000000..4c5c5f7 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/InheritanceAndNestedResolutionTest.java @@ -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 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 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 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 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 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 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 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 first = context.getImplementations("com.example.EventSource"); + List 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 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 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 chains = engine.findChains(List.of(entry), List.of(trigger)); + assertThat(chains).hasSize(1); + assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()) + .containsExactly("OrderEvent.SHIP"); + } + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/ReactiveExpressionSupportTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/ReactiveExpressionSupportTest.java new file mode 100644 index 0000000..aabf930 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/ReactiveExpressionSupportTest.java @@ -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()"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/TypeResolverTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/TypeResolverTest.java new file mode 100644 index 0000000..6940774 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/TypeResolverTest.java @@ -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); + } +}