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:
2026-07-12 06:02:48 +02:00
parent 613dfffd53
commit c2eff6d7bb
8 changed files with 221 additions and 24 deletions

View File

@@ -22,6 +22,7 @@ public final class AccessorFieldResolver {
public static List<String> resolveFieldConstants( public static List<String> resolveFieldConstants(
AccessorSummary accessor, AccessorSummary accessor,
String requestingTypeFqn,
CodebaseContext context, CodebaseContext context,
ConstructorAnalyzer constructorAnalyzer, ConstructorAnalyzer constructorAnalyzer,
CompilationUnit contextCu, CompilationUnit contextCu,
@@ -35,11 +36,14 @@ public final class AccessorFieldResolver {
return List.of(); return List.of();
} }
String traceTypeFqn = requestingTypeFqn != null && !requestingTypeFqn.isBlank()
? requestingTypeFqn
: accessor.declaringFqn();
TypeDeclaration typeDeclaration = contextCu != null TypeDeclaration typeDeclaration = contextCu != null
? context.getTypeDeclaration(accessor.declaringFqn(), contextCu) ? context.getTypeDeclaration(traceTypeFqn, contextCu)
: null; : null;
if (typeDeclaration == null) { if (typeDeclaration == null) {
typeDeclaration = context.getTypeDeclaration(accessor.declaringFqn()); typeDeclaration = context.getTypeDeclaration(traceTypeFqn);
} }
if (typeDeclaration == null) { if (typeDeclaration == null) {
return List.of(); return List.of();
@@ -53,7 +57,7 @@ public final class AccessorFieldResolver {
if (constants.isEmpty()) { if (constants.isEmpty()) {
collectFieldInitializerConstants( collectFieldInitializerConstants(
typeDeclaration, accessor.fieldName(), constantExtractor, constants); typeDeclaration, accessor.fieldName(), constantExtractor, constants, context);
} }
return constants; return constants;
} }
@@ -62,8 +66,9 @@ public final class AccessorFieldResolver {
TypeDeclaration typeDeclaration, TypeDeclaration typeDeclaration,
String fieldName, String fieldName,
BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> constantExtractor, BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> constantExtractor,
List<String> constants) { List<String> constants,
if (constantExtractor == null) { CodebaseContext context) {
if (constantExtractor == null || typeDeclaration == null) {
return; return;
} }
for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) { 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);
}
}
}
} }
} }

View File

@@ -262,8 +262,11 @@ public final class AccessorResolver {
if (constantExtractor != null) { if (constantExtractor != null) {
CompilationUnit contextCu = receiverContext != null ? receiverContext.contextCu() : 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( List<String> fieldConstants = constantExtractor.resolveIndexedGetterFieldConstants(
accessor, contextCu, visited); accessor, requestingTypeFqn, contextCu, visited);
if (!fieldConstants.isEmpty()) { if (!fieldConstants.isEmpty()) {
return fieldConstants; return fieldConstants;
} }
@@ -335,7 +338,7 @@ public final class AccessorResolver {
} }
if (constantExtractor != null) { if (constantExtractor != null) {
List<String> indexedConstants = constantExtractor.resolveIndexedGetterFieldConstants( List<String> indexedConstants = constantExtractor.resolveIndexedGetterFieldConstants(
indexedAccessor, receiverContext.contextCu(), visited); indexedAccessor, ownerFqn, receiverContext.contextCu(), visited);
if (!indexedConstants.isEmpty()) { if (!indexedConstants.isEmpty()) {
return indexedConstants; return indexedConstants;
} }

View File

@@ -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;
}
}

View File

@@ -5,6 +5,7 @@ import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver; import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder; import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper; 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.pipeline.ResolutionBudget;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge; import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
@@ -419,6 +420,15 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
exprNode = pe.getExpression(); 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 varName = null;
String methodName = null; String methodName = null;
String declaredType = null; String declaredType = null;

View File

@@ -320,24 +320,34 @@ public class ConstantExtractor {
|| td.isInterface() || td.isInterface()
|| Modifier.isAbstract(td.getModifiers()); || 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) { if (indexedAccessor.get().kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER) {
List<String> constantGetterValues = extractConstantGetterReturn( List<String> constantGetterValues = extractConstantGetterReturn(
indexedAccessor.get().ownerFqn(), methodName, contextCu, visited); indexedAccessor.get().ownerFqn(), methodName, contextCu, visited);
if (!constantGetterValues.isEmpty()) { if (!constantGetterValues.isEmpty()) {
if (!widenToImplementations) {
visited.remove(fqn); visited.remove(fqn);
return constantGetterValues; return constantGetterValues;
} }
constants.addAll(constantGetterValues);
} }
}
if (constants.isEmpty()) {
List<String> indexedConstants = resolveIndexedGetterFieldConstants( List<String> indexedConstants = resolveIndexedGetterFieldConstants(
indexedAccessor.get(), contextCu, visited); indexedAccessor.get(), className, contextCu, visited);
if (!indexedConstants.isEmpty()) { if (!indexedConstants.isEmpty()) {
if (!widenToImplementations) {
visited.remove(fqn); visited.remove(fqn);
return indexedConstants; return indexedConstants;
} }
constants.addAll(indexedConstants);
}
}
}
} }
List<String> constants = new ArrayList<>();
if (td != null) { if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) { 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); List<String> impls = context.getImplementations(className);
if (impls != null) { if (impls != null) {
for (String implName : impls) { for (String implName : impls) {
List<String> delegationResult = resolveMethodReturnConstant( List<String> delegationResult = resolveMethodReturnConstant(
implName, methodName, depth + 1, visited, contextCu, activeBudget); 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( public List<String> resolveIndexedGetterFieldConstants(
AccessorSummary accessor, AccessorSummary accessor,
String requestingTypeFqn,
CompilationUnit contextCu, CompilationUnit contextCu,
Set<String> visited) { Set<String> visited) {
return AccessorFieldResolver.resolveFieldConstants( return AccessorFieldResolver.resolveFieldConstants(
accessor, accessor,
requestingTypeFqn,
context, context,
constructorAnalyzer, constructorAnalyzer,
contextCu, contextCu,

View File

@@ -2,6 +2,7 @@ package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.LibraryHint; import click.kamil.springstatemachineexporter.analysis.model.LibraryHint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; 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.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -107,6 +108,10 @@ public class GenericEventDetector {
} }
private String extractEventFromReceiver(Expression receiver) { private String extractEventFromReceiver(Expression receiver) {
String reactivePayload = ReactiveExpressionSupport.extractPayload(receiver, constantResolver, context);
if (reactivePayload != null) {
return reactivePayload;
}
if (receiver == null) return null; if (receiver == null) return null;
if (receiver instanceof MethodInvocation mi) { if (receiver instanceof MethodInvocation mi) {
String mName = mi.getName().getIdentifier(); String mName = mi.getName().getIdentifier();

View File

@@ -43,6 +43,8 @@ class AccessorPipelineBugFixTest {
variableTracer = new VariableTracer(context, constantResolver); variableTracer = new VariableTracer(context, constantResolver);
constantExtractor.setVariableTracer(variableTracer); constantExtractor.setVariableTracer(variableTracer);
constantExtractor.setConstructorAnalyzer(constructorAnalyzer); constantExtractor.setConstructorAnalyzer(constructorAnalyzer);
constructorAnalyzer.setVariableTracer(variableTracer);
constructorAnalyzer.setConstantExtractor(constantExtractor);
accessorResolver = new AccessorResolver(context, constantExtractor, constructorAnalyzer, constantResolver); accessorResolver = new AccessorResolver(context, constantExtractor, constructorAnalyzer, constantResolver);
constantExtractor.setAccessorResolver(accessorResolver); constantExtractor.setAccessorResolver(accessorResolver);
} }
@@ -118,6 +120,37 @@ class AccessorPipelineBugFixTest {
assertThat(resolved).containsExactlyInAnyOrder("Code.A", "Code.B"); 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 @Nested

View File

@@ -1037,6 +1037,42 @@ class HeuristicCallGraphEngineCoreTest {
org.assertj.core.api.Assertions.assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactlyInAnyOrder("REACTIVE_EVENT"); 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 @Test
void shouldExtractEventFromSwitchExpressionAndParentheses(@TempDir Path tempDir) throws IOException { void shouldExtractEventFromSwitchExpressionAndParentheses(@TempDir Path tempDir) throws IOException {
String source = """ String source = """