diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorFieldResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorFieldResolver.java index d5ba741..b06e772 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorFieldResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorFieldResolver.java @@ -22,6 +22,7 @@ public final class AccessorFieldResolver { public static List resolveFieldConstants( AccessorSummary accessor, + String requestingTypeFqn, CodebaseContext context, ConstructorAnalyzer constructorAnalyzer, CompilationUnit contextCu, @@ -35,11 +36,14 @@ public final class AccessorFieldResolver { return List.of(); } + String traceTypeFqn = requestingTypeFqn != null && !requestingTypeFqn.isBlank() + ? requestingTypeFqn + : accessor.declaringFqn(); TypeDeclaration typeDeclaration = contextCu != null - ? context.getTypeDeclaration(accessor.declaringFqn(), contextCu) + ? context.getTypeDeclaration(traceTypeFqn, contextCu) : null; if (typeDeclaration == null) { - typeDeclaration = context.getTypeDeclaration(accessor.declaringFqn()); + typeDeclaration = context.getTypeDeclaration(traceTypeFqn); } if (typeDeclaration == null) { return List.of(); @@ -53,7 +57,7 @@ public final class AccessorFieldResolver { if (constants.isEmpty()) { collectFieldInitializerConstants( - typeDeclaration, accessor.fieldName(), constantExtractor, constants); + typeDeclaration, accessor.fieldName(), constantExtractor, constants, context); } return constants; } @@ -62,8 +66,9 @@ public final class AccessorFieldResolver { TypeDeclaration typeDeclaration, String fieldName, BiConsumer> constantExtractor, - List constants) { - if (constantExtractor == null) { + List constants, + CodebaseContext context) { + if (constantExtractor == null || typeDeclaration == null) { return; } for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) { @@ -75,5 +80,14 @@ public final class AccessorFieldResolver { } } } + if (constants.isEmpty() && context != null) { + String superFqn = context.getSuperclassFqn(typeDeclaration); + if (superFqn != null && !superFqn.equals("java.lang.Object")) { + TypeDeclaration superType = context.getTypeDeclaration(superFqn); + if (superType != null) { + collectFieldInitializerConstants(superType, fieldName, constantExtractor, constants, context); + } + } + } } } 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 75f08ea..1920c65 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 @@ -262,8 +262,11 @@ public final class AccessorResolver { if (constantExtractor != null) { CompilationUnit contextCu = receiverContext != null ? receiverContext.contextCu() : null; + String requestingTypeFqn = receiverContext != null && receiverContext.ownerType() != null + ? context.getFqn(receiverContext.ownerType()) + : accessor.ownerFqn(); List fieldConstants = constantExtractor.resolveIndexedGetterFieldConstants( - accessor, contextCu, visited); + accessor, requestingTypeFqn, contextCu, visited); if (!fieldConstants.isEmpty()) { return fieldConstants; } @@ -335,7 +338,7 @@ public final class AccessorResolver { } if (constantExtractor != null) { List indexedConstants = constantExtractor.resolveIndexedGetterFieldConstants( - indexedAccessor, receiverContext.contextCu(), visited); + indexedAccessor, ownerFqn, receiverContext.contextCu(), visited); if (!indexedConstants.isEmpty()) { return indexedConstants; } 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 new file mode 100644 index 0000000..c0702df --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/ReactiveExpressionSupport.java @@ -0,0 +1,80 @@ +package click.kamil.springstatemachineexporter.analysis.pipeline; + +import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.Block; +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 java.util.Set; + +/** + * Extracts terminal payload literals from reactive factory chains ({@code Mono.just}, nested {@code flatMap}). + */ +public final class ReactiveExpressionSupport { + + private static final Set REACTIVE_FACTORY_METHODS = Set.of("just", "withPayload", "success"); + + private ReactiveExpressionSupport() { + } + + public static String extractPayload(Expression expression, 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); + if (fromArgument != null) { + return fromArgument; + } + } + if (REACTIVE_FACTORY_METHODS.contains(methodName) && !mi.arguments().isEmpty()) { + Expression arg = (Expression) mi.arguments().get(0); + if (constantResolver != null && context != null) { + String resolved = constantResolver.resolve(arg, context); + if (resolved != null) { + return resolved; + } + } + return arg.toString(); + } + if (mi.getExpression() != null) { + return extractPayload(mi.getExpression(), constantResolver, context); + } + } + return null; + } + + private static String extractFlatMapArgumentPayload( + Expression argument, + ConstantResolver constantResolver, + CodebaseContext context) { + if (argument instanceof LambdaExpression lambda) { + Expression bodyExpression = lambdaBodyExpression(lambda); + if (bodyExpression != null) { + return extractPayload(bodyExpression, constantResolver, context); + } + } + return extractPayload(argument, constantResolver, context); + } + + private static Expression lambdaBodyExpression(LambdaExpression lambda) { + if (lambda.getBody() instanceof Expression bodyExpression) { + return bodyExpression; + } + if (lambda.getBody() instanceof Block block) { + for (Object statement : block.statements()) { + if (statement instanceof ReturnStatement returnStatement + && returnStatement.getExpression() != null) { + return returnStatement.getExpression(); + } + } + } + return null; + } +} 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 63bf389..8468fb1 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 @@ -5,6 +5,7 @@ import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver; import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder; import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper; +import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport; import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget; import click.kamil.springstatemachineexporter.analysis.model.CallEdge; import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; @@ -418,6 +419,15 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { while (exprNode instanceof ParenthesizedExpression pe) { exprNode = pe.getExpression(); } + + if (exprNode instanceof Expression expressionNode) { + String reactivePayload = ReactiveExpressionSupport.extractPayload( + expressionNode, constantResolver, context); + if (reactivePayload != null) { + polymorphicEvents.add(reactivePayload); + return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, polymorphicEvents); + } + } String varName = null; String methodName = null; 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 3b06902..83a5669 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 @@ -320,24 +320,34 @@ public class ConstantExtractor { || td.isInterface() || Modifier.isAbstract(td.getModifiers()); - if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter() && !widenToImplementations) { - if (indexedAccessor.get().kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER) { - List constantGetterValues = extractConstantGetterReturn( - indexedAccessor.get().ownerFqn(), methodName, contextCu, visited); - if (!constantGetterValues.isEmpty()) { - visited.remove(fqn); - return constantGetterValues; + List constants = new ArrayList<>(); + if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) { + boolean indexedOnRequestedType = className.equals(indexedAccessor.get().ownerFqn()); + if (!widenToImplementations || indexedOnRequestedType) { + if (indexedAccessor.get().kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER) { + List constantGetterValues = extractConstantGetterReturn( + indexedAccessor.get().ownerFqn(), methodName, contextCu, visited); + if (!constantGetterValues.isEmpty()) { + if (!widenToImplementations) { + visited.remove(fqn); + return constantGetterValues; + } + constants.addAll(constantGetterValues); + } + } + if (constants.isEmpty()) { + List indexedConstants = resolveIndexedGetterFieldConstants( + indexedAccessor.get(), className, contextCu, visited); + if (!indexedConstants.isEmpty()) { + if (!widenToImplementations) { + visited.remove(fqn); + return indexedConstants; + } + constants.addAll(indexedConstants); + } } } - List indexedConstants = resolveIndexedGetterFieldConstants( - indexedAccessor.get(), contextCu, visited); - if (!indexedConstants.isEmpty()) { - visited.remove(fqn); - return indexedConstants; - } } - - List constants = new ArrayList<>(); if (td != null) { MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); if (md != null && md.getBody() != null) { @@ -423,13 +433,17 @@ public class ConstantExtractor { } }); } - if (constants.isEmpty() && widenToImplementations) { + if (widenToImplementations) { List impls = context.getImplementations(className); if (impls != null) { for (String implName : impls) { List delegationResult = resolveMethodReturnConstant( implName, methodName, depth + 1, visited, contextCu, activeBudget); - constants.addAll(delegationResult); + for (String value : delegationResult) { + if (!constants.contains(value)) { + constants.add(value); + } + } } } } @@ -442,10 +456,12 @@ public class ConstantExtractor { public List resolveIndexedGetterFieldConstants( AccessorSummary accessor, + String requestingTypeFqn, CompilationUnit contextCu, Set visited) { return AccessorFieldResolver.resolveFieldConstants( accessor, + requestingTypeFqn, context, constructorAnalyzer, contextCu, 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 b36ab6b..cef8ed2 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 @@ -2,6 +2,7 @@ package click.kamil.springstatemachineexporter.analysis.service; import click.kamil.springstatemachineexporter.analysis.model.LibraryHint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import lombok.RequiredArgsConstructor; @@ -107,6 +108,10 @@ public class GenericEventDetector { } private String extractEventFromReceiver(Expression receiver) { + String reactivePayload = ReactiveExpressionSupport.extractPayload(receiver, constantResolver, context); + if (reactivePayload != null) { + return reactivePayload; + } if (receiver == null) return null; if (receiver instanceof MethodInvocation mi) { String mName = mi.getName().getIdentifier(); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/AccessorPipelineBugFixTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/AccessorPipelineBugFixTest.java index f8b180a..984561c 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/AccessorPipelineBugFixTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/AccessorPipelineBugFixTest.java @@ -43,6 +43,8 @@ class AccessorPipelineBugFixTest { 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); } @@ -118,6 +120,37 @@ class AccessorPipelineBugFixTest { assertThat(resolved).containsExactlyInAnyOrder("Code.A", "Code.B"); } + + @Test + void shouldWidenToImplsEvenWhenAbstractFieldHasInlineInitializer() throws IOException { + writeJava("com/example/Code.java", """ + package com.example; + public enum Code { PAY, SHIP } + """); + writeJava("com/example/BaseEvent.java", """ + package com.example; + public abstract class BaseEvent { + protected Code code = Code.PAY; + public Code getCode() { return code; } + } + """); + writeJava("com/example/DefaultEvent.java", """ + package com.example; + public class DefaultEvent extends BaseEvent {} + """); + writeJava("com/example/ShipEvent.java", """ + package com.example; + public class ShipEvent extends BaseEvent { + public ShipEvent() { this.code = Code.SHIP; } + } + """); + scanWorkspace(); + + List resolved = constantExtractor.resolveMethodReturnConstant( + "com.example.BaseEvent", "getCode", 0, new HashSet<>(), null); + + assertThat(resolved).containsExactlyInAnyOrder("Code.PAY", "Code.SHIP"); + } } @Nested diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineCoreTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineCoreTest.java index e32e768..64c2017 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineCoreTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineCoreTest.java @@ -1037,6 +1037,42 @@ class HeuristicCallGraphEngineCoreTest { org.assertj.core.api.Assertions.assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactlyInAnyOrder("REACTIVE_EVENT"); } + @Test + void shouldResolveEventFromNestedReactiveFlatMap(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + public class OrderController { + private StateMachine stateMachine; + public void handleReactive() { + Mono.just("IGNORED") + .flatMap(x -> Mono.just("NESTED_REACTIVE_EVENT")) + .flatMap(stateMachine::sendEvent); + } + } + + class StateMachine { + public void sendEvent(String event) {} + } + + class Mono { + public static Mono just(Object obj) { return new Mono(); } + public Mono flatMap(Object mapper) { return this; } + } + """; + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context); + + EntryPoint entryPoint = EntryPoint.builder().className("com.example.OrderController").methodName("handleReactive").build(); + TriggerPoint trigger = TriggerPoint.builder().className("com.example.StateMachine").methodName("sendEvent").event("event").build(); + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + org.assertj.core.api.Assertions.assertThat(chains).hasSize(1); + org.assertj.core.api.Assertions.assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()) + .containsExactlyInAnyOrder("NESTED_REACTIVE_EVENT"); + } + @Test void shouldExtractEventFromSwitchExpressionAndParentheses(@TempDir Path tempDir) throws IOException { String source = """