Fix abstract getter widening and nested reactive flatMap resolution.
Ensure abstract-type getter resolution merges subclass field values and trace indexed accessors on the requesting type, and extract terminal payloads from chained flatMap sendEvent calls. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -22,6 +22,7 @@ public final class AccessorFieldResolver {
|
||||
|
||||
public static List<String> 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<org.eclipse.jdt.core.dom.Expression, List<String>> constantExtractor,
|
||||
List<String> constants) {
|
||||
if (constantExtractor == null) {
|
||||
List<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> 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<String> indexedConstants = constantExtractor.resolveIndexedGetterFieldConstants(
|
||||
indexedAccessor, receiverContext.contextCu(), visited);
|
||||
indexedAccessor, ownerFqn, receiverContext.contextCu(), visited);
|
||||
if (!indexedConstants.isEmpty()) {
|
||||
return indexedConstants;
|
||||
}
|
||||
|
||||
@@ -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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -419,6 +420,15 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
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;
|
||||
String declaredType = null;
|
||||
|
||||
@@ -320,24 +320,34 @@ public class ConstantExtractor {
|
||||
|| td.isInterface()
|
||||
|| Modifier.isAbstract(td.getModifiers());
|
||||
|
||||
if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter() && !widenToImplementations) {
|
||||
List<String> 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<String> 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<String> indexedConstants = resolveIndexedGetterFieldConstants(
|
||||
indexedAccessor.get(), contextCu, visited);
|
||||
indexedAccessor.get(), className, contextCu, visited);
|
||||
if (!indexedConstants.isEmpty()) {
|
||||
if (!widenToImplementations) {
|
||||
visited.remove(fqn);
|
||||
return indexedConstants;
|
||||
}
|
||||
constants.addAll(indexedConstants);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<String> 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<String> impls = context.getImplementations(className);
|
||||
if (impls != null) {
|
||||
for (String implName : impls) {
|
||||
List<String> 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<String> resolveIndexedGetterFieldConstants(
|
||||
AccessorSummary accessor,
|
||||
String requestingTypeFqn,
|
||||
CompilationUnit contextCu,
|
||||
Set<String> visited) {
|
||||
return AccessorFieldResolver.resolveFieldConstants(
|
||||
accessor,
|
||||
requestingTypeFqn,
|
||||
context,
|
||||
constructorAnalyzer,
|
||||
contextCu,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String> resolved = constantExtractor.resolveMethodReturnConstant(
|
||||
"com.example.BaseEvent", "getCode", 0, new HashSet<>(), null);
|
||||
|
||||
assertThat(resolved).containsExactlyInAnyOrder("Code.PAY", "Code.SHIP");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
||||
@@ -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<CallChain> 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 = """
|
||||
|
||||
Reference in New Issue
Block a user