Unify accessor resolution through a shared pipeline and widen interface getters to all implementations.

Introduce AccessorResolver with session caching and ordered index-first fallbacks, replace fragmented unwrap/depth logic with shared utilities, and fix polymorphic event resolution that stopped after the first indexed implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-11 20:12:22 +02:00
parent 7f12ed3f3a
commit a108d8cc6e
22 changed files with 2176 additions and 318 deletions

View File

@@ -30,6 +30,10 @@ public final class AccessorFieldResolver {
if (accessor == null || !accessor.isGetter() || context == null) { if (accessor == null || !accessor.isGetter() || context == null) {
return List.of(); return List.of();
} }
if (accessor.kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER
|| accessor.fieldName() == null) {
return List.of();
}
TypeDeclaration typeDeclaration = contextCu != null TypeDeclaration typeDeclaration = contextCu != null
? context.getTypeDeclaration(accessor.declaringFqn(), contextCu) ? context.getTypeDeclaration(accessor.declaringFqn(), contextCu)

View File

@@ -3,6 +3,7 @@ package click.kamil.springstatemachineexporter.analysis.index;
public enum AccessorKind { public enum AccessorKind {
GETTER, GETTER,
BOOLEAN_GETTER, BOOLEAN_GETTER,
CONSTANT_GETTER,
RECORD_COMPONENT, RECORD_COMPONENT,
SETTER, SETTER,
FLUENT_SETTER FLUENT_SETTER

View File

@@ -12,6 +12,7 @@ public record AccessorSummary(
public boolean isGetter() { public boolean isGetter() {
return kind == AccessorKind.GETTER return kind == AccessorKind.GETTER
|| kind == AccessorKind.BOOLEAN_GETTER || kind == AccessorKind.BOOLEAN_GETTER
|| kind == AccessorKind.CONSTANT_GETTER
|| kind == AccessorKind.RECORD_COMPONENT; || kind == AccessorKind.RECORD_COMPONENT;
} }

View File

@@ -27,7 +27,11 @@ public class TrivialAccessorDetector {
Optional<String> getterField = propertyNameForGetter(methodName, method); Optional<String> getterField = propertyNameForGetter(methodName, method);
if (getterField.isPresent()) { if (getterField.isPresent()) {
return detectGetter(ownerFqn, method, methodName, getterField.get(), fieldLookup); Optional<AccessorSummary> fieldGetter = detectGetter(ownerFqn, method, methodName, getterField.get(), fieldLookup);
if (fieldGetter.isPresent()) {
return fieldGetter;
}
return detectConstantGetter(ownerFqn, method, methodName);
} }
Optional<String> setterField = propertyNameForSetter(methodName); Optional<String> setterField = propertyNameForSetter(methodName);
@@ -53,6 +57,51 @@ public class TrivialAccessorDetector {
)); ));
} }
private Optional<AccessorSummary> detectConstantGetter(
String ownerFqn,
MethodDeclaration method,
String methodName) {
List<?> statements = method.getBody().statements();
if (statements.size() != 1 || !(statements.get(0) instanceof ReturnStatement rs)) {
return Optional.empty();
}
Expression expr = rs.getExpression();
if (expr == null || !isConstantReturnExpression(expr)) {
return Optional.empty();
}
return Optional.of(new AccessorSummary(
ownerFqn,
methodName,
AccessorKind.CONSTANT_GETTER,
null,
null,
ownerFqn,
false
));
}
private boolean isConstantReturnExpression(Expression expr) {
Expression current = unwrap(expr);
if (current instanceof StringLiteral
|| current instanceof NumberLiteral
|| current instanceof BooleanLiteral
|| current instanceof CharacterLiteral) {
return true;
}
if (current instanceof QualifiedName) {
return !containsMethodCall(current);
}
if (current instanceof SimpleName) {
return false;
}
if (current instanceof FieldAccess fa) {
Expression receiver = fa.getExpression();
return (receiver instanceof SimpleName || receiver instanceof QualifiedName)
&& !containsMethodCall(current);
}
return false;
}
private Optional<AccessorSummary> detectGetter( private Optional<AccessorSummary> detectGetter(
String ownerFqn, String ownerFqn,
MethodDeclaration method, MethodDeclaration method,

View File

@@ -0,0 +1,416 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.analysis.index.AccessorKind;
import click.kamil.springstatemachineexporter.analysis.index.AccessorFieldResolver;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
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.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.BiConsumer;
/**
* Unified accessor resolution pipeline: index lookup first, then field/CIC/method-body fallbacks.
*/
public final class AccessorResolver {
public record GetterChainStep(String typeFqn, ClassInstanceCreation cic) {
}
public record ReceiverContext(
TypeDeclaration ownerType,
ClassInstanceCreation cic,
CompilationUnit contextCu,
boolean anonymousGetter,
Map<String, String> fieldValues) {
}
private final CodebaseContext context;
private final ConstantExtractor constantExtractor;
private final ConstructorAnalyzer constructorAnalyzer;
private final ConstantResolver constantResolver;
private final FieldInitializerFinder fieldInitializerFinder;
private final Map<String, List<String>> sessionCache = new HashMap<>();
public AccessorResolver(
CodebaseContext context,
ConstantExtractor constantExtractor,
ConstructorAnalyzer constructorAnalyzer,
ConstantResolver constantResolver) {
this.context = context;
this.constantExtractor = constantExtractor;
this.constructorAnalyzer = constructorAnalyzer;
this.constantResolver = constantResolver;
this.fieldInitializerFinder = new FieldInitializerFinder(context);
}
public Optional<AccessorSummary> lookupIndexedGetter(String className, String methodName) {
if (className == null || className.isBlank()) {
return Optional.empty();
}
Optional<AccessorSummary> direct = context.getAccessorIndex().lookup(className, methodName);
if (direct.isPresent()) {
return direct;
}
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
if (typeDeclaration != null) {
direct = context.getAccessorIndex().lookup(context.getFqn(typeDeclaration), methodName);
if (direct.isPresent()) {
return direct;
}
if (!typeDeclaration.isInterface() && !org.eclipse.jdt.core.dom.Modifier.isAbstract(typeDeclaration.getModifiers())) {
return Optional.empty();
}
}
List<String> implementations = context.getImplementations(className);
if (implementations == null) {
return Optional.empty();
}
for (String implementation : implementations) {
Optional<AccessorSummary> implAccessor = context.getAccessorIndex().lookup(implementation, methodName);
if (implAccessor.isPresent()) {
return implAccessor;
}
}
return Optional.empty();
}
public List<String> resolve(String ownerFqn, String methodName, ReceiverContext receiverContext, ResolutionBudget budget) {
return resolve(ownerFqn, methodName, receiverContext, budget, new HashSet<>());
}
public List<String> resolve(
String ownerFqn,
String methodName,
ReceiverContext receiverContext,
ResolutionBudget budget,
Set<String> visited) {
if (ownerFqn == null || methodName == null) {
return List.of();
}
if (budget == null) {
budget = ResolutionBudget.defaults();
}
String cacheKey = cacheKey(ownerFqn, methodName, receiverContext);
List<String> cached = sessionCache.get(cacheKey);
if (cached != null) {
return cached;
}
List<String> resolved = resolveInternal(ownerFqn, methodName, receiverContext, budget, visited);
if (!resolved.isEmpty()) {
List<String> cachedCopy = List.copyOf(resolved);
sessionCache.put(cacheKey, cachedCopy);
return cachedCopy;
}
return resolved;
}
public GetterChainStep resolveGetterChainStep(String ownerFqn, String getterName, ResolutionBudget budget) {
if (ownerFqn == null || getterName == null) {
return null;
}
Optional<AccessorSummary> indexedAccessor = lookupIndexedGetter(ownerFqn, getterName);
if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) {
AccessorSummary accessor = indexedAccessor.get();
ClassInstanceCreation nextCic = fieldInitializerFinder.findFieldInitializerCic(ownerFqn, accessor.fieldName());
String nextType = fieldInitializerFinder.resolveFieldTypeName(ownerFqn, accessor.fieldName());
if (nextCic != null) {
nextType = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(nextCic.getType());
}
if (nextType != null) {
return new GetterChainStep(nextType, nextCic);
}
}
String simpleType = simplifyTypeName(ownerFqn);
ClassInstanceCreation nextCic = constructorAnalyzer.findReturnedCIC(simpleType, getterName, context);
if (nextCic == null) {
String fieldName = indexedFieldName(ownerFqn, getterName);
nextCic = fieldInitializerFinder.findFieldInitializerCic(ownerFqn, fieldName);
}
if (nextCic != null) {
return new GetterChainStep(
click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(nextCic.getType()),
nextCic);
}
TypeDeclaration td = context.getTypeDeclaration(simpleType);
if (td == null) {
td = context.getTypeDeclaration(ownerFqn);
}
if (td != null) {
MethodDeclaration getter = context.findMethodDeclaration(td, getterName, true);
if (getter != null && getter.getReturnType2() != null) {
return new GetterChainStep(getter.getReturnType2().toString(), null);
}
}
return null;
}
public void clearSessionCache() {
sessionCache.clear();
}
private List<String> resolveInternal(
String ownerFqn,
String methodName,
ReceiverContext receiverContext,
ResolutionBudget budget,
Set<String> visited) {
TypeDeclaration ownerType = receiverContext != null ? receiverContext.ownerType() : null;
if (ownerType == null) {
ownerType = context.getTypeDeclaration(ownerFqn);
}
boolean widenToImplementations = ownerType == null
|| ownerType.isInterface()
|| org.eclipse.jdt.core.dom.Modifier.isAbstract(ownerType.getModifiers());
if (widenToImplementations) {
List<String> widened = new ArrayList<>();
List<String> impls = context.getImplementations(ownerFqn);
if (impls != null) {
for (String implFqn : impls) {
TypeDeclaration implType = context.getTypeDeclaration(implFqn);
widened.addAll(resolve(
implFqn,
methodName,
new ReceiverContext(implType, null,
receiverContext != null ? receiverContext.contextCu() : null,
false,
receiverContext != null ? receiverContext.fieldValues() : null),
budget,
visited));
}
}
if (!widened.isEmpty()) {
return deduplicatePreservingOrder(widened);
}
}
Optional<AccessorSummary> indexedAccessor = lookupIndexedGetter(ownerFqn, methodName);
if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) {
List<String> indexed = resolveIndexedAccessor(indexedAccessor.get(), receiverContext, visited);
if (!indexed.isEmpty()) {
return indexed;
}
}
if (receiverContext != null && receiverContext.ownerType() != null && receiverContext.cic() != null) {
List<String> cicValues = resolveWithCic(
ownerFqn, methodName, receiverContext, indexedAccessor.orElse(null), visited);
if (!cicValues.isEmpty()) {
return cicValues;
}
}
if (constantExtractor != null && !budget.isMethodReturnExhausted(0)) {
CompilationUnit contextCu = receiverContext != null ? receiverContext.contextCu() : null;
List<String> deep = constantExtractor.resolveMethodReturnConstant(
ownerFqn, methodName, 0, visited, contextCu, budget);
if (!deep.isEmpty()) {
return deep;
}
}
return List.of();
}
private List<String> resolveIndexedAccessor(
AccessorSummary accessor,
ReceiverContext receiverContext,
Set<String> visited) {
if (accessor.kind() == AccessorKind.CONSTANT_GETTER) {
List<String> constants = extractConstantGetterReturn(accessor.ownerFqn(), accessor.methodName());
if (!constants.isEmpty()) {
return constants;
}
if (constantExtractor != null) {
CompilationUnit contextCu = receiverContext != null ? receiverContext.contextCu() : null;
return constantExtractor.resolveMethodReturnConstant(
accessor.ownerFqn(), accessor.methodName(), 0, new HashSet<>(visited), contextCu);
}
}
if (constantExtractor != null) {
CompilationUnit contextCu = receiverContext != null ? receiverContext.contextCu() : null;
List<String> fieldConstants = constantExtractor.resolveIndexedGetterFieldConstants(
accessor, contextCu, visited);
if (!fieldConstants.isEmpty()) {
return fieldConstants;
}
}
if (receiverContext != null
&& receiverContext.cic() != null
&& receiverContext.fieldValues() != null
&& accessor.fieldName() != null) {
String fieldValue = receiverContext.fieldValues().get(accessor.fieldName());
if (fieldValue != null) {
return List.of(fieldValue);
}
}
return List.of();
}
private List<String> resolveWithCic(
String ownerFqn,
String methodName,
ReceiverContext receiverContext,
AccessorSummary indexedAccessor,
Set<String> visited) {
TypeDeclaration td = receiverContext.ownerType();
ClassInstanceCreation cic = receiverContext.cic();
Map<String, String> fieldValues = receiverContext.fieldValues() != null
? receiverContext.fieldValues()
: new HashMap<>();
MethodDeclaration getter = null;
if (cic.getAnonymousClassDeclaration() != null) {
for (Object declObj : cic.getAnonymousClassDeclaration().bodyDeclarations()) {
if (declObj instanceof MethodDeclaration md && md.getName().getIdentifier().equals(methodName)) {
getter = md;
break;
}
}
}
if (getter == null) {
getter = context.findMethodDeclaration(td, methodName, true);
}
if (getter != null && getter.getBody() != null) {
if (fieldValues.isEmpty() && constructorAnalyzer != null) {
fieldValues.putAll(constructorAnalyzer.buildFieldValuesFromCIC(td, cic, context, null));
}
String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, visited);
if (result != null) {
return List.of(qualifyEnumLikeResult(getter, result));
}
}
if (indexedAccessor != null && indexedAccessor.isGetter() && !receiverContext.anonymousGetter()) {
if (indexedAccessor.kind() == AccessorKind.CONSTANT_GETTER) {
List<String> constantValues = extractConstantGetterReturn(ownerFqn, methodName);
if (!constantValues.isEmpty()) {
return constantValues;
}
}
if (fieldValues.isEmpty() && constructorAnalyzer != null) {
fieldValues.putAll(constructorAnalyzer.buildFieldValuesFromCIC(td, cic, context, null));
}
if (indexedAccessor.fieldName() != null) {
String indexedFieldValue = fieldValues.get(indexedAccessor.fieldName());
if (indexedFieldValue != null) {
return List.of(indexedFieldValue);
}
}
if (constantExtractor != null) {
List<String> indexedConstants = constantExtractor.resolveIndexedGetterFieldConstants(
indexedAccessor, receiverContext.contextCu(), visited);
if (!indexedConstants.isEmpty()) {
return indexedConstants;
}
}
}
return List.of();
}
private List<String> extractConstantGetterReturn(String ownerFqn, String methodName) {
TypeDeclaration typeDeclaration = context.getTypeDeclaration(ownerFqn);
if (typeDeclaration == null) {
return List.of();
}
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
return List.of();
}
List<String> constants = new ArrayList<>();
BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> extractor =
constantExtractor != null
? constantExtractor::extractConstantsFromExpression
: (expr, out) -> {
String resolved = constantResolver.resolve(expr, context);
if (resolved != null) {
out.add(resolved);
}
};
methodDeclaration.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() != null) {
extractor.accept(node.getExpression(), constants);
}
return false;
}
});
return constants;
}
private static String qualifyEnumLikeResult(MethodDeclaration getter, String result) {
if (result.contains(".")) {
return result;
}
String returnType = getter.getReturnType2() != null ? getter.getReturnType2().toString() : null;
if (returnType == null) {
return result;
}
int lastDotRet = returnType.lastIndexOf('.');
String simpleReturnType = lastDotRet > 0 ? returnType.substring(lastDotRet + 1) : returnType;
if ("String".equals(simpleReturnType) || "Integer".equals(simpleReturnType)
|| "Long".equals(simpleReturnType) || "Boolean".equals(simpleReturnType)) {
return result;
}
return simpleReturnType + "." + result;
}
private String indexedFieldName(String ownerFqn, String getterName) {
Optional<AccessorSummary> accessorOpt = lookupIndexedGetter(ownerFqn, getterName);
if (accessorOpt.isEmpty() || accessorOpt.get().fieldName() == null) {
return null;
}
return accessorOpt.get().fieldName();
}
private static List<String> deduplicatePreservingOrder(List<String> values) {
List<String> deduped = new ArrayList<>();
for (String value : values) {
if (!deduped.contains(value)) {
deduped.add(value);
}
}
return deduped;
}
private static String cacheKey(String ownerFqn, String methodName, ReceiverContext receiverContext) {
String cicKey = receiverContext != null && receiverContext.cic() != null
? receiverContext.cic().toString()
: "";
return ownerFqn + "#" + methodName + "#" + cicKey;
}
private static String simplifyTypeName(String typeFqn) {
if (typeFqn == null) {
return null;
}
String simple = typeFqn.contains(".") ? typeFqn.substring(typeFqn.lastIndexOf('.') + 1) : typeFqn;
if (simple.contains("<")) {
simple = simple.substring(0, simple.indexOf('<'));
}
return simple;
}
}

View File

@@ -0,0 +1,94 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import java.util.HashSet;
import java.util.Set;
/**
* Finds {@code new ...()} field initializers, walking the inheritance chain once.
*/
public final class FieldInitializerFinder {
private final CodebaseContext context;
public FieldInitializerFinder(CodebaseContext context) {
this.context = context;
}
public ClassInstanceCreation findFieldInitializerCic(String ownerFqn, String fieldName) {
if (fieldName == null || ownerFqn == null) {
return null;
}
TypeDeclaration td = context.getTypeDeclaration(ownerFqn);
return findFieldInitializerCic(td, fieldName, new HashSet<>());
}
public ClassInstanceCreation findFieldInitializerCic(TypeDeclaration td, String fieldName, Set<String> visited) {
if (td == null || fieldName == null) {
return null;
}
String typeFqn = context.getFqn(td);
if (typeFqn == null || !visited.add(typeFqn)) {
return null;
}
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment frag
&& frag.getName().getIdentifier().equals(fieldName)
&& frag.getInitializer() instanceof ClassInstanceCreation cic) {
return cic;
}
}
}
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
ClassInstanceCreation inherited = findFieldInitializerCic(superTd, fieldName, visited);
if (inherited != null) {
return inherited;
}
}
return null;
}
public String resolveFieldTypeName(String ownerFqn, String fieldName) {
if (ownerFqn == null || fieldName == null) {
return null;
}
TypeDeclaration td = context.getTypeDeclaration(ownerFqn);
return resolveFieldTypeName(td, fieldName, new HashSet<>());
}
public String resolveFieldTypeName(TypeDeclaration td, String fieldName, Set<String> visited) {
if (td == null || fieldName == null) {
return null;
}
String typeFqn = context.getFqn(td);
if (typeFqn == null || !visited.add(typeFqn)) {
return null;
}
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment frag
&& frag.getName().getIdentifier().equals(fieldName)) {
return fd.getType().toString();
}
}
}
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
return resolveFieldTypeName(superTd, fieldName, visited);
}
return null;
}
}

View File

@@ -0,0 +1,59 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import java.util.Locale;
import java.util.Set;
/**
* Central registry for reactive/factory unwrap and passthrough method names used during constant extraction.
*/
public final class LibraryUnwrapRegistry {
private static final Set<String> UNWRAP_RECEIVER_TYPES = Set.of(
"Optional", "Stream", "List", "Set", "Arrays", "Objects", "Mono", "Flux");
private static final Set<String> UNWRAP_METHOD_NAMES = Set.of(
"of", "ofEntries", "asList", "entry", "just", "withPayload", "success");
private static final String[] STOP_UNWRAP_PREFIXES = {
"map", "parse", "convert", "to", "resolve", "build", "create", "from",
"transform", "translate", "derive", "determine", "calculate", "decode",
"extract", "get", "find", "validate"
};
private LibraryUnwrapRegistry() {
}
public static boolean isUnwrapReceiverType(String receiverExpressionText) {
return receiverExpressionText != null && UNWRAP_RECEIVER_TYPES.contains(receiverExpressionText);
}
public static boolean isUnwrapMethodName(String methodName) {
return methodName != null && UNWRAP_METHOD_NAMES.contains(methodName);
}
public static boolean shouldStopUnwrap(String methodName) {
if (methodName == null) {
return true;
}
String lowerName = methodName.toLowerCase(Locale.ROOT);
for (String prefix : STOP_UNWRAP_PREFIXES) {
if (lowerName.startsWith(prefix)) {
return true;
}
}
return false;
}
public static boolean isEventSetterMethodName(String methodName) {
if (methodName == null) {
return false;
}
return "event".equalsIgnoreCase(methodName)
|| "type".equalsIgnoreCase(methodName)
|| "eventType".equalsIgnoreCase(methodName);
}
public static boolean isHeaderSetterMethodName(String methodName) {
return "setHeader".equals(methodName);
}
}

View File

@@ -0,0 +1,136 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.ParenthesizedExpression;
import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
/**
* Shared {@link MethodInvocation} peeling for factory/reactive wrappers and local passthrough methods.
*/
public final class MethodInvocationUnwrapper {
private MethodInvocationUnwrapper() {
}
public static Expression unwrap(
MethodInvocation mi,
int depth,
String methodFqn,
CodebaseContext context,
Function<MethodInvocation, String> targetMethodFqnResolver) {
if (ResolutionBudget.defaults().isUnwrapExhausted(depth)) {
return mi;
}
String targetMethodFqn = targetMethodFqnResolver != null
? targetMethodFqnResolver.apply(mi)
: null;
if (targetMethodFqn == null && methodFqn != null && methodFqn.contains(".") && mi.getExpression() == null) {
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
targetMethodFqn = className + "." + mi.getName().getIdentifier();
}
if (targetMethodFqn != null && targetMethodFqn.contains(".")) {
String className = targetMethodFqn.substring(0, targetMethodFqn.lastIndexOf('.'));
String methodName = targetMethodFqn.substring(targetMethodFqn.lastIndexOf('.') + 1);
TypeDeclaration currentTd = methodFqn != null && methodFqn.contains(".")
? context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')))
: null;
org.eclipse.jdt.core.dom.CompilationUnit cu = currentTd != null && currentTd.getRoot() instanceof org.eclipse.jdt.core.dom.CompilationUnit compilationUnit
? compilationUnit
: null;
TypeDeclaration td = context.getTypeDeclaration(className, cu);
if (td == null) {
td = context.getTypeDeclaration(className);
}
if (td != null) {
MethodDeclaration localMd = context.findMethodDeclaration(td, methodName, true);
if (localMd != null) {
int paramIdx = getReturnedParameterIndex(localMd);
if (paramIdx >= 0 && paramIdx < mi.arguments().size()) {
Expression arg = peelExpression((Expression) mi.arguments().get(paramIdx));
if (arg instanceof MethodInvocation innerMi) {
return unwrap(innerMi, depth + 1, methodFqn, context, targetMethodFqnResolver);
}
return arg;
}
}
}
}
if (mi.getExpression() != null) {
String exprStr = mi.getExpression().toString();
if (!LibraryUnwrapRegistry.isUnwrapReceiverType(exprStr)) {
return mi;
}
} else if (targetMethodFqn == null) {
return mi;
}
if (!mi.arguments().isEmpty()) {
String methodName = mi.getName().getIdentifier();
if (LibraryUnwrapRegistry.shouldStopUnwrap(methodName)) {
return mi;
}
Expression arg = peelExpression((Expression) mi.arguments().get(0));
if (arg instanceof MethodInvocation innerMi) {
return unwrap(innerMi, depth + 1, methodFqn, context, targetMethodFqnResolver);
}
return arg;
}
return mi;
}
public static Expression peelExpression(Expression expression) {
Expression current = expression;
while (current instanceof ParenthesizedExpression pe) {
current = pe.getExpression();
}
return current;
}
public static int getReturnedParameterIndex(MethodDeclaration md) {
if (md.getBody() == null) {
return -1;
}
List<?> parameters = md.parameters();
if (parameters.isEmpty()) {
return -1;
}
final List<String> returnedExprs = new ArrayList<>();
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() != null) {
returnedExprs.add(node.getExpression().toString());
}
return super.visit(node);
}
});
if (returnedExprs.size() == 1) {
String retStr = returnedExprs.get(0);
for (int i = 0; i < parameters.size(); i++) {
if (parameters.get(i) instanceof SingleVariableDeclaration svd
&& svd.getName().getIdentifier().equals(retStr)) {
return i;
}
}
}
return -1;
}
}

View File

@@ -0,0 +1,115 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
/**
* Shared depth and fallback budget for analysis traversals. Replaces scattered magic-number limits.
*/
public final class ResolutionBudget {
public static final int DEFAULT_METHOD_RETURN = 20;
public static final int DEFAULT_ACCESSOR_INLINE = 25;
public static final int DEFAULT_DATAFLOW = 50;
public static final int DEFAULT_UNWRAP = 5;
public static final int DEFAULT_CONSTRUCTOR = 10;
public static final int DEFAULT_RETURNED_CIC = 50;
public static final int DEFAULT_GLOBAL_FIELD_SCAN = 10;
private final int methodReturnDepth;
private final int accessorInlineDepth;
private final int dataflowDepth;
private final int unwrapDepth;
private final int constructorDepth;
private final int returnedCicDepth;
private final int globalFieldScanLimit;
private final boolean allowGlobalFieldScan;
private ResolutionBudget(
int methodReturnDepth,
int accessorInlineDepth,
int dataflowDepth,
int unwrapDepth,
int constructorDepth,
int returnedCicDepth,
int globalFieldScanLimit,
boolean allowGlobalFieldScan) {
this.methodReturnDepth = methodReturnDepth;
this.accessorInlineDepth = accessorInlineDepth;
this.dataflowDepth = dataflowDepth;
this.unwrapDepth = unwrapDepth;
this.constructorDepth = constructorDepth;
this.returnedCicDepth = returnedCicDepth;
this.globalFieldScanLimit = globalFieldScanLimit;
this.allowGlobalFieldScan = allowGlobalFieldScan;
}
public static ResolutionBudget defaults() {
return new ResolutionBudget(
DEFAULT_METHOD_RETURN,
DEFAULT_ACCESSOR_INLINE,
DEFAULT_DATAFLOW,
DEFAULT_UNWRAP,
DEFAULT_CONSTRUCTOR,
DEFAULT_RETURNED_CIC,
DEFAULT_GLOBAL_FIELD_SCAN,
false);
}
public static ResolutionBudget withGlobalFieldScan() {
return defaults().enableGlobalFieldScan();
}
public ResolutionBudget enableGlobalFieldScan() {
return new ResolutionBudget(
methodReturnDepth,
accessorInlineDepth,
dataflowDepth,
unwrapDepth,
constructorDepth,
returnedCicDepth,
globalFieldScanLimit,
true);
}
public ResolutionBudget child() {
return new ResolutionBudget(
methodReturnDepth - 1,
accessorInlineDepth - 1,
dataflowDepth - 1,
unwrapDepth,
constructorDepth - 1,
returnedCicDepth - 1,
globalFieldScanLimit,
allowGlobalFieldScan);
}
public boolean isMethodReturnExhausted(int depth) {
return depth > methodReturnDepth;
}
public boolean isAccessorInlineExhausted(int depth) {
return depth > accessorInlineDepth;
}
public boolean isDataflowExhausted(int depth) {
return depth > dataflowDepth;
}
public boolean isUnwrapExhausted(int depth) {
return depth > unwrapDepth;
}
public boolean isConstructorExhausted(int depth) {
return depth > constructorDepth;
}
public boolean isReturnedCicExhausted(int depth) {
return depth > returnedCicDepth;
}
public int globalFieldScanLimit() {
return globalFieldScanLimit;
}
public boolean allowGlobalFieldScan() {
return allowGlobalFieldScan;
}
}

View File

@@ -2,6 +2,10 @@ package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; 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.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;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
@@ -23,6 +27,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
protected final ConstantExtractor constantExtractor; protected final ConstantExtractor constantExtractor;
protected final ConstructorAnalyzer constructorAnalyzer; protected final ConstructorAnalyzer constructorAnalyzer;
protected final ExpressionAccessClassifier expressionAccessClassifier; protected final ExpressionAccessClassifier expressionAccessClassifier;
protected final AccessorResolver accessorResolver;
protected final FieldInitializerFinder fieldInitializerFinder;
private final Map<String, ASTNode> parsedNodeCache = new HashMap<>(); private final Map<String, ASTNode> parsedNodeCache = new HashMap<>();
private ASTNode parseExpressionString(String expr) { private ASTNode parseExpressionString(String expr) {
@@ -65,7 +71,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
public AbstractCallGraphEngine(CodebaseContext context) { public AbstractCallGraphEngine(CodebaseContext context) {
this.context = context; this.context = context;
this.constantResolver = new ConstantResolver(); this.constantResolver = context.getConstantResolver();
this.typeResolver = new TypeResolver(context); this.typeResolver = new TypeResolver(context);
this.pathFinder = new CallGraphPathFinder(context); this.pathFinder = new CallGraphPathFinder(context);
@@ -82,6 +88,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
this.expressionAccessClassifier = new ExpressionAccessClassifier( this.expressionAccessClassifier = new ExpressionAccessClassifier(
context, variableTracer, constantResolver, this::parseExpressionString); context, variableTracer, constantResolver, this::parseExpressionString);
this.constantExtractor.setExpressionAccessClassifier(this.expressionAccessClassifier); this.constantExtractor.setExpressionAccessClassifier(this.expressionAccessClassifier);
this.accessorResolver = new AccessorResolver(context, constantExtractor, constructorAnalyzer, constantResolver);
this.constantExtractor.setAccessorResolver(this.accessorResolver);
this.fieldInitializerFinder = new FieldInitializerFinder(context);
} }
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) { public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
@@ -297,94 +306,35 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
String entryMethod = path.get(0); String entryMethod = path.get(0);
if (methodSuffix.startsWith(".get") || methodSuffix.contains(".type") || methodSuffix.contains(".event")) { if (methodSuffix != null && expressionAccessClassifier.isTraceableAccessorSuffix(methodSuffix)) {
List<String> chainedGetterEvents = evaluateGetterOnReceiver(resolvedValue, methodSuffix, entryMethod, path, callGraph); List<String> chainedGetterEvents = evaluateGetterOnReceiver(resolvedValue, methodSuffix, entryMethod, path, callGraph);
if (chainedGetterEvents != null && !chainedGetterEvents.isEmpty()) { if (chainedGetterEvents != null && !chainedGetterEvents.isEmpty()) {
log.debug("Early return (chained getter): getterEvents = {}", chainedGetterEvents); log.debug("Early return (chained getter): getterEvents = {}", chainedGetterEvents);
return TriggerPoint.builder() return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, chainedGetterEvents);
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
.sourceModule(tp.getSourceModule())
.stateMachineId(tp.getStateMachineId())
.sourceState(tp.getSourceState())
.lineNumber(tp.getLineNumber())
.polymorphicEvents(chainedGetterEvents)
.constraint(tp.getConstraint())
.stateTypeFqn(tp.getStateTypeFqn())
.eventTypeFqn(tp.getEventTypeFqn())
.external(tp.isExternal())
.build();
} }
if (methodSuffix.startsWith(".get") || methodSuffix.equals(".type") || methodSuffix.equals(".event") if (expressionAccessClassifier.isTraceableAccessorSuffix(methodSuffix)) {
|| methodSuffix.equals(".type()") || methodSuffix.equals(".event()")) { List<String> setterEvents = resolveSetterEventsFromSuffix(currentParamName, methodSuffix, entryMethod, path);
String localMethodName = methodSuffix.substring(1).replace("()", ""); if (!setterEvents.isEmpty()) {
String scopeForVar = resolveScopeMethodForVariable(currentParamName, entryMethod, path); log.debug("Early return (scoped setter): setterEvents = {}", setterEvents);
Expression localSetterExpr = variableTracer.traceLocalSetter(scopeForVar, currentParamName, localMethodName); return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, setterEvents);
if (localSetterExpr != null) {
List<String> setterEvents = new ArrayList<>();
List<Expression> tracedSetters = variableTracer.traceVariableAll(localSetterExpr);
for (Expression tracedSetter : tracedSetters) {
constantExtractor.extractConstantsFromExpression(tracedSetter, setterEvents);
}
if (!setterEvents.isEmpty()) {
log.debug("Early return (scoped setter): setterEvents = {}", setterEvents);
return TriggerPoint.builder()
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
.sourceModule(tp.getSourceModule())
.stateMachineId(tp.getStateMachineId())
.sourceState(tp.getSourceState())
.lineNumber(tp.getLineNumber())
.polymorphicEvents(setterEvents)
.constraint(tp.getConstraint())
.stateTypeFqn(tp.getStateTypeFqn())
.eventTypeFqn(tp.getEventTypeFqn())
.external(tp.isExternal())
.build();
}
} }
} }
} }
int entryParamIndex = typeResolver.getParameterIndex(entryMethod, currentParamName); int entryParamIndex = typeResolver.getParameterIndex(entryMethod, currentParamName);
if (entryParamIndex < 0) { if (entryParamIndex < 0) {
if (methodSuffix.startsWith(".get") || methodSuffix.equals(".type") || methodSuffix.equals(".event") || methodSuffix.equals(".type()") || methodSuffix.equals(".event()")) { if (methodSuffix != null && expressionAccessClassifier.isTraceableAccessorSuffix(methodSuffix)) {
String localMethodName = methodSuffix.substring(1).replace("()", ""); List<String> setterEvents = resolveSetterEventsFromSuffix(currentParamName, methodSuffix, entryMethod, path);
String scopeForVar = resolveScopeMethodForVariable(currentParamName, entryMethod, path); if (!setterEvents.isEmpty()) {
Expression localSetterExpr = variableTracer.traceLocalSetter(scopeForVar, currentParamName, localMethodName); log.debug("Early return 1: setterEvents = {}", setterEvents);
if (localSetterExpr != null) { return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, setterEvents);
List<String> setterEvents = new ArrayList<>();
List<Expression> tracedSetters = variableTracer.traceVariableAll(localSetterExpr);
for (Expression tracedSetter : tracedSetters) {
constantExtractor.extractConstantsFromExpression(tracedSetter, setterEvents);
}
if (!setterEvents.isEmpty()) {
log.debug("Early return 1: setterEvents = {}", setterEvents);
return TriggerPoint.builder()
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
.sourceModule(tp.getSourceModule())
.stateMachineId(tp.getStateMachineId())
.sourceState(tp.getSourceState())
.lineNumber(tp.getLineNumber())
.polymorphicEvents(setterEvents)
.constraint(tp.getConstraint())
.stateTypeFqn(tp.getStateTypeFqn())
.eventTypeFqn(tp.getEventTypeFqn())
.external(tp.isExternal())
.build();
}
} }
} }
boolean hasGetterOnReceiver = (methodSuffix.startsWith(".get") || methodSuffix.equals(".type") || methodSuffix.equals(".event") || methodSuffix.equals(".type()") || methodSuffix.equals(".event()")) && currentParamName != null boolean hasGetterOnReceiver = methodSuffix != null
&& expressionAccessClassifier.isTraceableAccessorSuffix(methodSuffix)
&& currentParamName != null
&& (currentParamName.contains("new ") || (!currentParamName.replaceFirst("^this\\.", "").contains(".") && !currentParamName.contains("("))); && (currentParamName.contains("new ") || (!currentParamName.replaceFirst("^this\\.", "").contains(".") && !currentParamName.contains("(")));
if (hasGetterOnReceiver) { if (hasGetterOnReceiver) {
List<String> getterEvents = evaluateGetterOnReceiver(resolvedValue, methodSuffix, entryMethod, path, callGraph); List<String> getterEvents = evaluateGetterOnReceiver(resolvedValue, methodSuffix, entryMethod, path, callGraph);
@@ -1153,6 +1103,43 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
* Resolves a nested getter prefix such as {@code wrapper.getEvent()} to the declared return type * Resolves a nested getter prefix such as {@code wrapper.getEvent()} to the declared return type
* and, when possible, a {@code new ...()} instance from the getter body. * and, when possible, a {@code new ...()} instance from the getter body.
*/ */
private TriggerPoint buildTriggerPointWithPolymorphicEvents(TriggerPoint tp, String resolvedValue, List<String> polymorphicEvents) {
return TriggerPoint.builder()
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
.sourceModule(tp.getSourceModule())
.stateMachineId(tp.getStateMachineId())
.sourceState(tp.getSourceState())
.lineNumber(tp.getLineNumber())
.polymorphicEvents(polymorphicEvents)
.constraint(tp.getConstraint())
.stateTypeFqn(tp.getStateTypeFqn())
.eventTypeFqn(tp.getEventTypeFqn())
.external(tp.isExternal())
.build();
}
private List<String> resolveSetterEventsFromSuffix(
String currentParamName,
String methodSuffix,
String entryMethod,
List<String> path) {
String localMethodName = methodSuffix.substring(1).replace("()", "");
String scopeForVar = resolveScopeMethodForVariable(currentParamName, entryMethod, path);
Expression localSetterExpr = variableTracer.traceLocalSetter(scopeForVar, currentParamName, localMethodName);
if (localSetterExpr == null) {
return List.of();
}
List<String> setterEvents = new ArrayList<>();
List<Expression> tracedSetters = variableTracer.traceVariableAll(localSetterExpr);
for (Expression tracedSetter : tracedSetters) {
constantExtractor.extractConstantsFromExpression(tracedSetter, setterEvents);
}
return setterEvents;
}
protected GetterChainReceiver resolveGetterChainToReceiver(Expression expr, String entryMethod) { protected GetterChainReceiver resolveGetterChainToReceiver(Expression expr, String entryMethod) {
if (expr instanceof SimpleName sn) { if (expr instanceof SimpleName sn) {
String type = variableTracer.getVariableDeclaredType(entryMethod, sn.getIdentifier()); String type = variableTracer.getVariableDeclaredType(entryMethod, sn.getIdentifier());
@@ -1187,10 +1174,15 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
String getterName = mi.getName().getIdentifier(); String getterName = mi.getName().getIdentifier();
String simpleType = simplifyTypeName(base.typeFqn()); String simpleType = simplifyTypeName(base.typeFqn());
ClassInstanceCreation nextCic = base.cic(); String ownerFqn = resolveOwnerFqn(simpleType, base.typeFqn());
if (nextCic == null) {
nextCic = constructorAnalyzer.findReturnedCIC(simpleType, getterName, context); AccessorResolver.GetterChainStep indexedStep =
accessorResolver.resolveGetterChainStep(ownerFqn, getterName, ResolutionBudget.defaults());
if (indexedStep != null) {
return new GetterChainReceiver(indexedStep.typeFqn(), indexedStep.cic());
} }
ClassInstanceCreation nextCic = constructorAnalyzer.findReturnedCIC(simpleType, getterName, context);
if (nextCic != null) { if (nextCic != null) {
return new GetterChainReceiver( return new GetterChainReceiver(
click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(nextCic.getType()), click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(nextCic.getType()),
@@ -1210,6 +1202,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return null; return null;
} }
private String resolveOwnerFqn(String simpleType, String typeFqn) {
TypeDeclaration td = context.getTypeDeclaration(simpleType);
if (td == null && typeFqn != null) {
td = context.getTypeDeclaration(typeFqn);
}
return td != null ? context.getFqn(td) : typeFqn;
}
private static String simplifyTypeName(String typeFqn) { private static String simplifyTypeName(String typeFqn) {
if (typeFqn == null) { if (typeFqn == null) {
return null; return null;
@@ -1232,6 +1232,31 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return entryMethod; return entryMethod;
} }
private String resolveClassForSuperCall(String entryMethod, List<String> path) {
if (path != null) {
for (String methodFqn : path) {
if (methodFqn == null) {
continue;
}
int lastDot = methodFqn.lastIndexOf('.');
if (lastDot <= 0) {
continue;
}
String className = methodFqn.substring(0, lastDot);
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
if (typeDeclaration == null) {
continue;
}
String superFqn = context.getSuperclassFqn(typeDeclaration);
if (superFqn != null && !"java.lang.Object".equals(superFqn)) {
return className;
}
}
}
int lastDot = entryMethod.lastIndexOf('.');
return lastDot > 0 ? entryMethod.substring(0, lastDot) : entryMethod;
}
protected List<String> evaluateGetterOnReceiver(String resolvedValue, String methodSuffix, String entryMethod, List<String> path, Map<String, List<CallEdge>> callGraph) { protected List<String> evaluateGetterOnReceiver(String resolvedValue, String methodSuffix, String entryMethod, List<String> path, Map<String, List<CallEdge>> callGraph) {
ASTNode exprNode = parseExpressionString(resolvedValue); ASTNode exprNode = parseExpressionString(resolvedValue);
while (exprNode instanceof ParenthesizedExpression pe) { while (exprNode instanceof ParenthesizedExpression pe) {
@@ -1251,12 +1276,31 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (exprNode instanceof SuperMethodInvocation smi) { if (exprNode instanceof SuperMethodInvocation smi) {
String getterName = smi.getName().getIdentifier(); String getterName = smi.getName().getIdentifier();
int lastDot = entryMethod.lastIndexOf('.'); String className = resolveClassForSuperCall(entryMethod, path);
String className = lastDot > 0 ? entryMethod.substring(0, lastDot) : entryMethod;
TypeDeclaration currentTd = context.getTypeDeclaration(className); TypeDeclaration currentTd = context.getTypeDeclaration(className);
if (currentTd != null) { if (currentTd != null) {
String receiverType = context.getSuperclassFqn(currentTd); String receiverType = context.getSuperclassFqn(currentTd);
return constantExtractor.resolveMethodReturnConstant(receiverType, getterName, 0, new HashSet<>(), null); if (receiverType != null) {
Optional<AccessorSummary> indexedSuperGetter =
context.getAccessorIndex().lookup(receiverType, getterName);
if (indexedSuperGetter.isPresent() && indexedSuperGetter.get().isGetter()) {
CompilationUnit contextCu = currentTd.getRoot() instanceof CompilationUnit cu ? cu : null;
if (indexedSuperGetter.get().kind()
== click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER) {
List<String> constants = constantExtractor.resolveMethodReturnConstant(
receiverType, getterName, 0, new HashSet<>(), contextCu);
if (!constants.isEmpty()) {
return constants;
}
}
List<String> fieldConstants = constantExtractor.resolveIndexedGetterFieldConstants(
indexedSuperGetter.get(), contextCu, new HashSet<>());
if (!fieldConstants.isEmpty()) {
return fieldConstants;
}
}
return constantExtractor.resolveMethodReturnConstant(receiverType, getterName, 0, new HashSet<>(), null);
}
} }
} else if (exprNode instanceof MethodInvocation mi) { } else if (exprNode instanceof MethodInvocation mi) {
String getterName = mi.getName().getIdentifier(); String getterName = mi.getName().getIdentifier();
@@ -1301,9 +1345,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (currentTd != null) { if (currentTd != null) {
receiverType = context.getSuperclassFqn(currentTd); receiverType = context.getSuperclassFqn(currentTd);
} }
} else if (receiver instanceof FieldAccess fa) {
receiverName = fa.getName().getIdentifier();
if (fa.resolveFieldBinding() != null && fa.resolveFieldBinding().getType() != null) {
receiverType = fa.resolveFieldBinding().getType().getErasure().getQualifiedName();
}
} }
if (receiverType == null && path.size() > 1) { if (receiverType == null && receiverName != null && path.size() > 1) {
String callerMethod = path.get(1); String callerMethod = path.get(1);
receiverType = variableTracer.getVariableDeclaredType(callerMethod, receiverName); receiverType = variableTracer.getVariableDeclaredType(callerMethod, receiverName);
} }
@@ -1350,53 +1399,55 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
cic = (ClassInstanceCreation) initExpr; cic = (ClassInstanceCreation) initExpr;
} }
} }
if (cic == null) {
return null;
}
MethodDeclaration getter = null; MethodDeclaration getter = null;
if (cic.getAnonymousClassDeclaration() != null) { boolean anonymousGetter = false;
for (Object declObj : cic.getAnonymousClassDeclaration().bodyDeclarations()) { if (cic == null) {
if (declObj instanceof MethodDeclaration md && md.getName().getIdentifier().equals(getterName)) { String ownerFqn = context.getFqn(td);
getter = md; List<String> resolvedWithoutCic = accessorResolver.resolve(
break; ownerFqn,
} getterName,
new AccessorResolver.ReceiverContext(td, null, contextCu, false, fieldValues),
ResolutionBudget.defaults());
if (!resolvedWithoutCic.isEmpty()) {
return resolvedWithoutCic;
} }
} } else {
if (getter == null) { if (cic.getAnonymousClassDeclaration() != null) {
getter = context.findMethodDeclaration(td, getterName, true); for (Object declObj : cic.getAnonymousClassDeclaration().bodyDeclarations()) {
} if (declObj instanceof MethodDeclaration md && md.getName().getIdentifier().equals(getterName)) {
if ((getterName.startsWith("get") && getterName.length() > 3) getter = md;
|| (getterName.startsWith("is") && getterName.length() > 2) anonymousGetter = true;
|| context.getAccessorIndex().lookup(context.getFqn(td), getterName).isPresent()) { break;
Optional<AccessorSummary> indexedGetter =
context.getAccessorIndex().lookup(context.getFqn(td), getterName);
if (indexedGetter.isPresent() && indexedGetter.get().isGetter()) {
fieldValues.putAll(constructorAnalyzer.buildFieldValuesFromCIC(
td, cic, context, this::evaluateMethodCallInConstructor));
String indexedFieldValue = fieldValues.get(indexedGetter.get().fieldName());
if (indexedFieldValue != null) {
return Collections.singletonList(indexedFieldValue);
}
List<String> indexedConstants = constantExtractor.resolveIndexedGetterFieldConstants(
indexedGetter.get(), contextCu, new HashSet<>());
if (!indexedConstants.isEmpty()) {
return indexedConstants;
}
}
}
if (getter != null && getter.getBody() != null) {
fieldValues.putAll(constructorAnalyzer.buildFieldValuesFromCIC(td, cic, context, this::evaluateMethodCallInConstructor));
String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, new HashSet<>());
if (result != null) {
String returnType = getter.getReturnType2() != null ? getter.getReturnType2().toString() : null;
if (returnType != null && !result.contains(".")) {
int lastDotRet = returnType.lastIndexOf('.');
String simpleReturnType = lastDotRet > 0 ? returnType.substring(lastDotRet + 1) : returnType;
if (!simpleReturnType.equals("String") && !simpleReturnType.equals("Integer") && !simpleReturnType.equals("Long") && !simpleReturnType.equals("Boolean")) {
result = simpleReturnType + "." + result;
} }
} }
return Collections.singletonList(result); }
if (getter == null) {
getter = context.findMethodDeclaration(td, getterName, true);
}
if (getter != null && getter.getBody() != null) {
fieldValues.putAll(constructorAnalyzer.buildFieldValuesFromCIC(
td, cic, context, this::evaluateMethodCallInConstructor));
String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, new HashSet<>());
if (result != null) {
String returnType = getter.getReturnType2() != null ? getter.getReturnType2().toString() : null;
if (returnType != null && !result.contains(".")) {
int lastDotRet = returnType.lastIndexOf('.');
String simpleReturnType = lastDotRet > 0 ? returnType.substring(lastDotRet + 1) : returnType;
if (!simpleReturnType.equals("String") && !simpleReturnType.equals("Integer")
&& !simpleReturnType.equals("Long") && !simpleReturnType.equals("Boolean")) {
result = simpleReturnType + "." + result;
}
}
return Collections.singletonList(result);
}
}
List<String> resolvedWithCic = accessorResolver.resolve(
context.getFqn(td),
getterName,
new AccessorResolver.ReceiverContext(td, cic, contextCu, anonymousGetter, fieldValues),
ResolutionBudget.defaults());
if (!resolvedWithCic.isEmpty()) {
return resolvedWithCic;
} }
} }
} }
@@ -1484,90 +1535,21 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return new String[] { paramName, currentSuffix }; return new String[] { paramName, currentSuffix };
} }
private int getReturnedParameterIndex(MethodDeclaration md) {
if (md.getBody() == null) return -1;
List<?> parameters = md.parameters();
if (parameters.isEmpty()) return -1;
final List<String> returnedExprs = new ArrayList<>();
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() != null) {
returnedExprs.add(node.getExpression().toString());
}
return super.visit(node);
}
});
if (returnedExprs.size() == 1) {
String retStr = returnedExprs.get(0);
for (int i = 0; i < parameters.size(); i++) {
if (parameters.get(i) instanceof SingleVariableDeclaration svd) {
if (svd.getName().getIdentifier().equals(retStr)) {
return i;
}
}
}
}
return -1;
}
protected Expression unwrapMethodInvocation(MethodInvocation mi, int depth) { protected Expression unwrapMethodInvocation(MethodInvocation mi, int depth) {
return unwrapMethodInvocation(mi, depth, null); return unwrapMethodInvocation(mi, depth, null);
} }
protected Expression unwrapMethodInvocation(MethodInvocation mi, int depth, String methodFqn) { protected Expression unwrapMethodInvocation(MethodInvocation mi, int depth, String methodFqn) {
if (depth > 5) return mi; return MethodInvocationUnwrapper.unwrap(mi, depth, methodFqn, context, miArg -> {
if (mi.getExpression() != null) { if (methodFqn == null || !methodFqn.contains(".")) {
String exprStr = mi.getExpression().toString(); return null;
if (!exprStr.equals("Optional") && !exprStr.equals("Stream") && !exprStr.equals("List") && !exprStr.equals("Set") && !exprStr.equals("Arrays") && !exprStr.equals("Objects") && !exprStr.equals("Mono") && !exprStr.equals("Flux")) {
return mi;
} }
} else { String currentClass = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
if (methodFqn != null && methodFqn.contains(".")) { if (miArg.getExpression() == null || miArg.getExpression().toString().equals("this")) {
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); return currentClass + "." + miArg.getName().getIdentifier();
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration localMd = context.findMethodDeclaration(td, mi.getName().getIdentifier(), true);
if (localMd != null) {
int paramIdx = getReturnedParameterIndex(localMd);
if (paramIdx >= 0 && paramIdx < mi.arguments().size()) {
Expression arg = (Expression) mi.arguments().get(paramIdx);
if (arg instanceof MethodInvocation innerMi) {
return unwrapMethodInvocation(innerMi, depth + 1, methodFqn);
}
return arg;
}
}
}
} }
return mi; return null;
} });
if (!mi.arguments().isEmpty()) {
String mName = mi.getName().getIdentifier();
String lowerName = mName.toLowerCase();
if (lowerName.startsWith("map") || lowerName.startsWith("parse") ||
lowerName.startsWith("convert") || lowerName.startsWith("to") ||
lowerName.startsWith("resolve") || lowerName.startsWith("build") ||
lowerName.startsWith("create") || lowerName.startsWith("from") ||
lowerName.startsWith("transform") || lowerName.startsWith("translate") ||
lowerName.startsWith("derive") || lowerName.startsWith("determine") ||
lowerName.startsWith("calculate") || lowerName.startsWith("decode") ||
lowerName.startsWith("extract") || lowerName.startsWith("get") ||
lowerName.startsWith("find")) {
return mi;
}
Expression arg = (Expression) mi.arguments().get(0);
if (arg instanceof MethodInvocation innerMi) {
return unwrapMethodInvocation(innerMi, depth + 1, methodFqn);
}
return arg;
}
return mi;
} }
public List<String> resolveClassConstantReturns(String className, CodebaseContext context, CompilationUnit contextCu) { public List<String> resolveClassConstantReturns(String className, CodebaseContext context, CompilationUnit contextCu) {

View File

@@ -1,8 +1,11 @@
package click.kamil.springstatemachineexporter.analysis.service; package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.index.AccessorFieldResolver; import click.kamil.springstatemachineexporter.analysis.index.AccessorFieldResolver;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*; import org.eclipse.jdt.core.dom.*;
@@ -17,6 +20,7 @@ public class ConstantExtractor {
private VariableTracer variableTracer; private VariableTracer variableTracer;
private ConstructorAnalyzer constructorAnalyzer; private ConstructorAnalyzer constructorAnalyzer;
private ExpressionAccessClassifier expressionAccessClassifier; private ExpressionAccessClassifier expressionAccessClassifier;
private AccessorResolver accessorResolver;
public ConstantExtractor(CodebaseContext context, ConstantResolver constantResolver, Function<MethodInvocation, String> calledMethodResolver) { public ConstantExtractor(CodebaseContext context, ConstantResolver constantResolver, Function<MethodInvocation, String> calledMethodResolver) {
this.context = context; this.context = context;
@@ -36,6 +40,10 @@ public class ConstantExtractor {
this.expressionAccessClassifier = expressionAccessClassifier; this.expressionAccessClassifier = expressionAccessClassifier;
} }
public void setAccessorResolver(AccessorResolver accessorResolver) {
this.accessorResolver = accessorResolver;
}
public void extractConstantsFromExpression(Expression expr, List<String> constants) { public void extractConstantsFromExpression(Expression expr, List<String> constants) {
if (constantResolver != null) { if (constantResolver != null) {
String resolved = constantResolver.resolve(expr, context); String resolved = constantResolver.resolve(expr, context);
@@ -98,8 +106,7 @@ public class ConstantExtractor {
return; return;
} }
} }
if (methodName.equals("of") || methodName.equals("ofEntries") || methodName.equals("asList") || methodName.equals("entry") || if (LibraryUnwrapRegistry.isUnwrapMethodName(methodName)) {
methodName.equals("just") || methodName.equals("withPayload") || methodName.equals("success")) {
for (Object argObj : mi.arguments()) { for (Object argObj : mi.arguments()) {
extractConstantsFromExpression((Expression) argObj, constants); extractConstantsFromExpression((Expression) argObj, constants);
} }
@@ -210,9 +217,9 @@ public class ConstantExtractor {
Expression currentMi = mi; Expression currentMi = mi;
while (currentMi instanceof MethodInvocation chainMi) { while (currentMi instanceof MethodInvocation chainMi) {
String chainName = chainMi.getName().getIdentifier(); String chainName = chainMi.getName().getIdentifier();
if (chainName.equals("setHeader") && chainMi.arguments().size() == 2) { if (LibraryUnwrapRegistry.isHeaderSetterMethodName(chainName) && chainMi.arguments().size() == 2) {
extractConstantsFromExpression((Expression) chainMi.arguments().get(1), constants); extractConstantsFromExpression((Expression) chainMi.arguments().get(1), constants);
} else if ((chainName.equalsIgnoreCase("event") || chainName.equalsIgnoreCase("type") || chainName.equalsIgnoreCase("eventType")) && chainMi.arguments().size() == 1) { } else if (LibraryUnwrapRegistry.isEventSetterMethodName(chainName) && chainMi.arguments().size() == 1) {
extractConstantsFromExpression((Expression) chainMi.arguments().get(0), constants); extractConstantsFromExpression((Expression) chainMi.arguments().get(0), constants);
} }
currentMi = chainMi.getExpression(); currentMi = chainMi.getExpression();
@@ -277,9 +284,22 @@ public class ConstantExtractor {
} }
public List<String> resolveMethodReturnConstant(String className, String methodName, int depth, Set<String> visited, CompilationUnit contextCu) { public List<String> resolveMethodReturnConstant(String className, String methodName, int depth, Set<String> visited, CompilationUnit contextCu) {
return resolveMethodReturnConstant(className, methodName, depth, visited, contextCu, ResolutionBudget.defaults());
}
public List<String> resolveMethodReturnConstant(
String className,
String methodName,
int depth,
Set<String> visited,
CompilationUnit contextCu,
ResolutionBudget budget) {
log.debug("ConstantExtractor.resolveMethodReturnConstant CALLED: className={}, methodName={}", className, methodName); log.debug("ConstantExtractor.resolveMethodReturnConstant CALLED: className={}, methodName={}", className, methodName);
log.debug("Entering resolveMethodReturnConstant with className={}, methodName={}, depth={}", className, methodName, depth); log.debug("Entering resolveMethodReturnConstant with className={}, methodName={}, depth={}", className, methodName, depth);
if (depth > 20) { if (budget == null) {
budget = ResolutionBudget.defaults();
}
if (budget.isMethodReturnExhausted(depth)) {
log.debug("Exiting resolveMethodReturnConstant early (depth limit) for {}.{}", className, methodName); log.debug("Exiting resolveMethodReturnConstant early (depth limit) for {}.{}", className, methodName);
return Collections.emptyList(); return Collections.emptyList();
} }
@@ -289,8 +309,25 @@ public class ConstantExtractor {
return Collections.emptyList(); return Collections.emptyList();
} }
Optional<AccessorSummary> indexedAccessor = context.getAccessorIndex().lookup(className, methodName); Optional<AccessorSummary> indexedAccessor = lookupIndexedGetter(className, methodName, budget);
if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) { TypeDeclaration tempTd = contextCu != null ? context.getTypeDeclaration(className, contextCu) : null;
if (tempTd == null) {
tempTd = context.getTypeDeclaration(className);
}
final TypeDeclaration td = tempTd;
boolean widenToImplementations = td == null
|| 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<String> constantGetterValues = extractConstantGetterReturn(
indexedAccessor.get().ownerFqn(), methodName, contextCu, visited);
if (!constantGetterValues.isEmpty()) {
visited.remove(fqn);
return constantGetterValues;
}
}
List<String> indexedConstants = resolveIndexedGetterFieldConstants( List<String> indexedConstants = resolveIndexedGetterFieldConstants(
indexedAccessor.get(), contextCu, visited); indexedAccessor.get(), contextCu, visited);
if (!indexedConstants.isEmpty()) { if (!indexedConstants.isEmpty()) {
@@ -300,11 +337,6 @@ public class ConstantExtractor {
} }
List<String> constants = new ArrayList<>(); List<String> constants = new ArrayList<>();
TypeDeclaration tempTd = contextCu != null ? context.getTypeDeclaration(className, contextCu) : null;
if (tempTd == null) {
tempTd = context.getTypeDeclaration(className);
}
final TypeDeclaration td = tempTd;
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) {
@@ -387,11 +419,12 @@ public class ConstantExtractor {
return super.visit(node); return super.visit(node);
} }
}); });
} else { } else 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(implName, methodName, depth + 1, visited, contextCu); List<String> delegationResult = resolveMethodReturnConstant(
implName, methodName, depth + 1, visited, contextCu, budget);
constants.addAll(delegationResult); constants.addAll(delegationResult);
} }
} }
@@ -416,6 +449,75 @@ public class ConstantExtractor {
this::extractConstantsFromExpression); this::extractConstantsFromExpression);
} }
public Optional<AccessorSummary> lookupIndexedGetter(String className, String methodName) {
return lookupIndexedGetter(className, methodName, ResolutionBudget.defaults());
}
private Optional<AccessorSummary> lookupIndexedGetter(String className, String methodName, ResolutionBudget budget) {
if (accessorResolver != null) {
return accessorResolver.lookupIndexedGetter(className, methodName);
}
if (className == null || className.isBlank()) {
return Optional.empty();
}
Optional<AccessorSummary> direct = context.getAccessorIndex().lookup(className, methodName);
if (direct.isPresent()) {
return direct;
}
TypeDeclaration typeDeclaration = context.getTypeDeclaration(className);
if (typeDeclaration != null) {
direct = context.getAccessorIndex().lookup(context.getFqn(typeDeclaration), methodName);
if (direct.isPresent()) {
return direct;
}
if (!typeDeclaration.isInterface() && !Modifier.isAbstract(typeDeclaration.getModifiers())) {
return Optional.empty();
}
}
List<String> implementations = context.getImplementations(className);
if (implementations == null) {
return Optional.empty();
}
for (String implementation : implementations) {
Optional<AccessorSummary> implAccessor = context.getAccessorIndex().lookup(implementation, methodName);
if (implAccessor.isPresent()) {
return implAccessor;
}
}
return Optional.empty();
}
private List<String> extractConstantGetterReturn(
String ownerFqn,
String methodName,
CompilationUnit contextCu,
Set<String> visited) {
TypeDeclaration typeDeclaration = contextCu != null
? context.getTypeDeclaration(ownerFqn, contextCu)
: null;
if (typeDeclaration == null) {
typeDeclaration = context.getTypeDeclaration(ownerFqn);
}
if (typeDeclaration == null) {
return List.of();
}
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
return List.of();
}
List<String> constants = new ArrayList<>();
methodDeclaration.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() != null) {
extractConstantsFromExpression(node.getExpression(), constants);
}
return false;
}
});
return constants;
}
public String parseEnumSetElement(String eVal) { public String parseEnumSetElement(String eVal) {
return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal; return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal;
} }

View File

@@ -1,6 +1,7 @@
package click.kamil.springstatemachineexporter.analysis.service; package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*; import org.eclipse.jdt.core.dom.*;
import java.util.*; import java.util.*;
@@ -33,6 +34,15 @@ public class ConstructorAnalyzer {
} }
public List<String> traceFieldInConstructors(TypeDeclaration td, String fieldName, CodebaseContext context, Set<String> visited) { public List<String> traceFieldInConstructors(TypeDeclaration td, String fieldName, CodebaseContext context, Set<String> visited) {
return traceFieldInConstructors(td, fieldName, context, visited, ResolutionBudget.defaults());
}
public List<String> traceFieldInConstructors(
TypeDeclaration td,
String fieldName,
CodebaseContext context,
Set<String> visited,
ResolutionBudget budget) {
if (td == null || fieldName == null) return Collections.emptyList(); if (td == null || fieldName == null) return Collections.emptyList();
String fqn = context.getFqn(td); String fqn = context.getFqn(td);
if (fqn == null) return Collections.emptyList(); if (fqn == null) return Collections.emptyList();
@@ -154,7 +164,7 @@ public class ConstructorAnalyzer {
if (superFqn != null && !superFqn.equals("java.lang.Object")) { if (superFqn != null && !superFqn.equals("java.lang.Object")) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn); TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
if (superTd != null) { if (superTd != null) {
results.addAll(traceFieldInConstructors(superTd, fieldName, context, visited)); results.addAll(traceFieldInConstructors(superTd, fieldName, context, visited, budget));
} }
} }
} }
@@ -166,23 +176,23 @@ public class ConstructorAnalyzer {
for (String implFqn : impls) { for (String implFqn : impls) {
TypeDeclaration implTd = context.getTypeDeclaration(implFqn); TypeDeclaration implTd = context.getTypeDeclaration(implFqn);
if (implTd != null) { if (implTd != null) {
results.addAll(traceFieldInConstructors(implTd, fieldName, context, visited)); results.addAll(traceFieldInConstructors(implTd, fieldName, context, visited, budget));
} }
} }
} }
} }
if (results.isEmpty()) { if (results.isEmpty() && budget != null && budget.allowGlobalFieldScan()) {
// Global Fallback: Search all parsed classes in the workspace // Global Fallback: Search all parsed classes in the workspace (explicit opt-in only)
Collection<TypeDeclaration> allTypes = context.getTypeDeclarations(); Collection<TypeDeclaration> allTypes = context.getTypeDeclarations();
if (allTypes != null) { if (allTypes != null) {
int matchedCount = 0; int matchedCount = 0;
for (TypeDeclaration typeDecl : allTypes) { for (TypeDeclaration typeDecl : allTypes) {
if (typeDecl == null) continue; if (typeDecl == null) continue;
if (context.hasField(typeDecl, fieldName)) { if (context.hasField(typeDecl, fieldName)) {
results.addAll(traceFieldInConstructors(typeDecl, fieldName, context, visited)); results.addAll(traceFieldInConstructors(typeDecl, fieldName, context, visited, budget));
matchedCount++; matchedCount++;
if (matchedCount >= 10) { if (matchedCount >= budget.globalFieldScanLimit()) {
break; break;
} }
} }
@@ -465,7 +475,7 @@ public class ConstructorAnalyzer {
private ClassInstanceCreation findReturnedCIC( private ClassInstanceCreation findReturnedCIC(
String className, String methodName, CodebaseContext context, String className, String methodName, CodebaseContext context,
Set<String> visited, int depth) { Set<String> visited, int depth) {
if (depth > 50 || !visited.add(className + "#" + methodName)) { if (ResolutionBudget.defaults().isReturnedCicExhausted(depth) || !visited.add(className + "#" + methodName)) {
return null; return null;
} }

View File

@@ -98,6 +98,28 @@ public final class ExpressionAccessClassifier {
return classifyMethodInvocation(mi, scopeMethod) == AccessKind.KEYED_LOOKUP; return classifyMethodInvocation(mi, scopeMethod) == AccessKind.KEYED_LOOKUP;
} }
/**
* Whether a trailing suffix like {@code .getEvent()} or {@code .type()} should be resolved via accessor tracing.
*/
public boolean isTraceableAccessorSuffix(String suffix) {
if (suffix == null || !suffix.startsWith(".")) {
return false;
}
String methodPart = suffix.substring(1);
if (methodPart.endsWith("()")) {
methodPart = methodPart.substring(0, methodPart.length() - 2);
}
return isBeanAccessorName(methodPart) || isRecordStyleAccessor(methodPart);
}
public boolean isTraceableAccessorExpression(String expression, String scopeMethod) {
if (expression == null || expression.isBlank()) {
return false;
}
AccessKind kind = classifyTrailingAccess(expression, scopeMethod);
return kind == AccessKind.TRACEABLE_ACCESSOR;
}
private boolean receiverExpressionIsMapLiteral(Expression receiver) { private boolean receiverExpressionIsMapLiteral(Expression receiver) {
if (constantResolver == null || context == null || receiver == null) { if (constantResolver == null || context == null || receiver == null) {
return false; return false;

View File

@@ -21,7 +21,7 @@ import java.util.Map;
public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider { public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
private final CodebaseContext context; private final CodebaseContext context;
private final Path rootDir; private final Path rootDir;
private final ConstantResolver constantResolver = new ConstantResolver(); private final ConstantResolver constantResolver;
private final GenericEventDetector eventDetector; private final GenericEventDetector eventDetector;
private final SpringMvcDetector mvcDetector; private final SpringMvcDetector mvcDetector;
private final MessagingDetector messagingDetector; private final MessagingDetector messagingDetector;
@@ -35,6 +35,7 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
public JdtIntelligenceProvider(CodebaseContext context, Path rootDir) { public JdtIntelligenceProvider(CodebaseContext context, Path rootDir) {
this.context = context; this.context = context;
this.rootDir = rootDir; this.rootDir = rootDir;
this.constantResolver = context.getConstantResolver();
this.eventDetector = new GenericEventDetector(context, constantResolver, context.getLibraryHints()); this.eventDetector = new GenericEventDetector(context, constantResolver, context.getLibraryHints());
this.mvcDetector = new SpringMvcDetector(context, constantResolver); this.mvcDetector = new SpringMvcDetector(context, constantResolver);
this.messagingDetector = new MessagingDetector(context); this.messagingDetector = new MessagingDetector(context);

View File

@@ -1,6 +1,8 @@
package click.kamil.springstatemachineexporter.analysis.service; package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge; import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.index.TrivialAccessorDetector; import click.kamil.springstatemachineexporter.analysis.index.TrivialAccessorDetector;
@@ -720,62 +722,7 @@ public class VariableTracer {
} }
private Expression unwrapMethodInvocation(MethodInvocation mi, int depth, String methodFqn) { private Expression unwrapMethodInvocation(MethodInvocation mi, int depth, String methodFqn) {
if (depth > 5) return mi; return MethodInvocationUnwrapper.unwrap(mi, depth, methodFqn, context, miArg -> resolveTargetMethodFqn(miArg, methodFqn));
String targetMethodFqn = resolveTargetMethodFqn(mi, methodFqn);
if (targetMethodFqn != null && targetMethodFqn.contains(".")) {
String className = targetMethodFqn.substring(0, targetMethodFqn.lastIndexOf('.'));
String methodName = targetMethodFqn.substring(targetMethodFqn.lastIndexOf('.') + 1);
TypeDeclaration currentTd = methodFqn != null && methodFqn.contains(".") ? context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.'))) : null;
CompilationUnit cu = currentTd != null && currentTd.getRoot() instanceof CompilationUnit ? (CompilationUnit) currentTd.getRoot() : null;
TypeDeclaration td = context.getTypeDeclaration(className, cu);
if (td != null) {
MethodDeclaration localMd = context.findMethodDeclaration(td, methodName, true);
if (localMd != null) {
int paramIdx = getReturnedParameterIndex(localMd);
if (paramIdx >= 0 && paramIdx < mi.arguments().size()) {
Expression arg = peelExpression((Expression) mi.arguments().get(paramIdx));
if (arg instanceof MethodInvocation innerMi) {
return unwrapMethodInvocation(innerMi, depth + 1, methodFqn);
}
return arg;
}
}
}
}
if (mi.getExpression() != null) {
String exprStr = mi.getExpression().toString();
if (!exprStr.equals("Optional") && !exprStr.equals("Stream") && !exprStr.equals("List") && !exprStr.equals("Set") && !exprStr.equals("Arrays") && !exprStr.equals("Objects") && !exprStr.equals("Mono") && !exprStr.equals("Flux")) {
return mi;
}
}
if (!mi.arguments().isEmpty()) {
String mName = mi.getName().getIdentifier();
String lowerName = mName.toLowerCase();
if (lowerName.startsWith("map") || lowerName.startsWith("parse") ||
lowerName.startsWith("convert") || lowerName.startsWith("to") ||
lowerName.startsWith("resolve") || lowerName.startsWith("build") ||
lowerName.startsWith("create") || lowerName.startsWith("from") ||
lowerName.startsWith("transform") || lowerName.startsWith("translate") ||
lowerName.startsWith("derive") || lowerName.startsWith("determine") ||
lowerName.startsWith("calculate") || lowerName.startsWith("decode") ||
lowerName.startsWith("extract") || lowerName.startsWith("get") ||
lowerName.startsWith("find") || lowerName.startsWith("validate")) {
return mi;
}
Expression arg = peelExpression((Expression) mi.arguments().get(0));
if (arg instanceof MethodInvocation innerMi) {
return unwrapMethodInvocation(innerMi, depth + 1, methodFqn);
}
return arg;
}
return mi;
} }
private String resolveTargetMethodFqn(MethodInvocation mi, String currentMethodFqn) { private String resolveTargetMethodFqn(MethodInvocation mi, String currentMethodFqn) {
@@ -797,33 +744,4 @@ public class VariableTracer {
} }
return null; return null;
} }
private int getReturnedParameterIndex(MethodDeclaration md) {
if (md.getBody() == null) return -1;
List<?> parameters = md.parameters();
if (parameters.isEmpty()) return -1;
final List<String> returnedExprs = new ArrayList<>();
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() != null) {
returnedExprs.add(node.getExpression().toString());
}
return super.visit(node);
}
});
if (returnedExprs.size() == 1) {
String retStr = returnedExprs.get(0);
for (int i = 0; i < parameters.size(); i++) {
if (parameters.get(i) instanceof SingleVariableDeclaration svd) {
if (svd.getName().getIdentifier().equals(retStr)) {
return i;
}
}
}
}
return -1;
}
} }

View File

@@ -2,6 +2,7 @@ package click.kamil.springstatemachineexporter.ast.common;
import click.kamil.springstatemachineexporter.analysis.index.AccessorInlining; import click.kamil.springstatemachineexporter.analysis.index.AccessorInlining;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
import org.eclipse.jdt.core.dom.*; import org.eclipse.jdt.core.dom.*;
import java.util.*; import java.util.*;
@@ -62,7 +63,7 @@ public class JdtDataFlowModel implements DataFlowModel {
Map<IVariableBinding, Expression> paramBindings, Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings, Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) { int depth) {
if (expr == null || depth > 50 || !visited.add(expr)) { if (expr == null || ResolutionBudget.defaults().isDataflowExhausted(depth) || !visited.add(expr)) {
return List.of(); return List.of();
} }
@@ -269,6 +270,19 @@ public class JdtDataFlowModel implements DataFlowModel {
// Handle Super Method Invocations // Handle Super Method Invocations
if (expr instanceof SuperMethodInvocation smi) { if (expr instanceof SuperMethodInvocation smi) {
IMethodBinding mb = smi.resolveMethodBinding(); IMethodBinding mb = smi.resolveMethodBinding();
if (mb != null && mb.getDeclaringClass() != null) {
String superFqn = mb.getDeclaringClass().getErasure().getQualifiedName();
String methodName = smi.getName().getIdentifier();
Optional<AccessorSummary> accessor = context.getAccessorIndex().lookup(superFqn, methodName);
if (accessor.isPresent() && accessor.get().isGetter()) {
Expression fieldValue = readFieldValue(accessor.get(), instanceFieldBindings, smi);
if (fieldValue != null) {
visited.remove(expr);
return getReachingDefinitions(
fieldValue, visited, paramBindings, instanceFieldBindings, depth + 1);
}
}
}
if (mb != null) { if (mb != null) {
MethodDeclaration md = findMethodDeclaration(mb); MethodDeclaration md = findMethodDeclaration(mb);
if (md != null && md.getBody() != null) { if (md != null && md.getBody() != null) {
@@ -309,7 +323,7 @@ public class JdtDataFlowModel implements DataFlowModel {
Map<IVariableBinding, Expression> paramBindings, Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings, Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) { int depth) {
if (depth > 25) { if (ResolutionBudget.defaults().isAccessorInlineExhausted(depth)) {
return List.of(); return List.of();
} }
@@ -359,6 +373,10 @@ public class JdtDataFlowModel implements DataFlowModel {
return List.of(); return List.of();
} }
if (accessor.get().kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER) {
return inlineConstantGetter(accessor.get(), visited, paramBindings, instanceFieldBindings, depth);
}
if (accessor.get().kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.RECORD_COMPONENT) { if (accessor.get().kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.RECORD_COMPONENT) {
return inlineRecordComponentRead(mi, accessor.get(), receiverDefs, visited, paramBindings, instanceFieldBindings, depth); return inlineRecordComponentRead(mi, accessor.get(), receiverDefs, visited, paramBindings, instanceFieldBindings, depth);
} }
@@ -366,6 +384,34 @@ public class JdtDataFlowModel implements DataFlowModel {
return inlineAccessorGetter(mi, accessor.get(), receiverDefs, visited, paramBindings, instanceFieldBindings, depth); return inlineAccessorGetter(mi, accessor.get(), receiverDefs, visited, paramBindings, instanceFieldBindings, depth);
} }
private List<Expression> inlineConstantGetter(
AccessorSummary accessor,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
TypeDeclaration typeDeclaration = context.getTypeDeclaration(accessor.ownerFqn());
if (typeDeclaration == null) {
return List.of();
}
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, accessor.methodName(), true);
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
return List.of();
}
List<Expression> results = new ArrayList<>();
for (Object statementObj : methodDeclaration.getBody().statements()) {
if (statementObj instanceof ReturnStatement returnStatement && returnStatement.getExpression() != null) {
results.addAll(getReachingDefinitions(
returnStatement.getExpression(),
visited,
paramBindings,
instanceFieldBindings,
depth + 1));
}
}
return results;
}
private boolean shouldSkipAccessorDueToConcreteOverride( private boolean shouldSkipAccessorDueToConcreteOverride(
String declaringFqn, String declaringFqn,
String methodName, String methodName,
@@ -375,9 +421,6 @@ public class JdtDataFlowModel implements DataFlowModel {
if (concreteFqn == null || concreteFqn.equals(declaringFqn)) { if (concreteFqn == null || concreteFqn.equals(declaringFqn)) {
continue; continue;
} }
if (context.getAccessorIndex().lookup(concreteFqn, methodName).isPresent()) {
continue;
}
TypeDeclaration concreteType = context.getTypeDeclaration(concreteFqn); TypeDeclaration concreteType = context.getTypeDeclaration(concreteFqn);
if (concreteType != null && context.hasMethod(concreteType, methodName)) { if (concreteType != null && context.hasMethod(concreteType, methodName)) {
return true; return true;
@@ -864,7 +907,7 @@ public class JdtDataFlowModel implements DataFlowModel {
Map<IVariableBinding, Expression> callerParamValues, Map<IVariableBinding, Expression> callerParamValues,
Map<IVariableBinding, Expression> fieldBindings, Map<IVariableBinding, Expression> fieldBindings,
int depth) { int depth) {
if (cb == null || depth > 10) return; if (cb == null || ResolutionBudget.defaults().isConstructorExhausted(depth)) return;
MethodDeclaration cd = findMethodDeclaration(cb); MethodDeclaration cd = findMethodDeclaration(cb);
if (cd == null || cd.getBody() == null) return; if (cd == null || cd.getBody() == null) return;

View File

@@ -1,5 +1,6 @@
package click.kamil.springstatemachineexporter.analysis.index; package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.analysis.index.AccessorKind;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.common.DataFlowModel; import click.kamil.springstatemachineexporter.ast.common.DataFlowModel;
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel; import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
@@ -98,6 +99,40 @@ class AccessorInliningDataFlowTest {
assertResolvedValue(tempDir, "value", "BASE"); assertResolvedValue(tempDir, "value", "BASE");
} }
@Test
void shouldResolveSuperTrivialGetterWithoutOpeningSuperBody(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Base.java", """
package com.example;
public class Base {
protected String event = "SUPER";
public String getEvent() { return event; }
}
""");
writeJava(tempDir, "com/example/Child.java", """
package com.example;
public class Child extends Base {
public String capture() {
return super.getEvent();
}
}
""");
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
Child child = new Child();
String value = child.capture();
}
}
""");
CodebaseContext context = scan(tempDir);
DataFlowModel model = new JdtDataFlowModel(context);
Expression initializer = findInitializer(context, "run", "value");
String resolved = model.resolveValue(initializer, context);
assertThat(resolved).isEqualTo("SUPER");
}
@Test @Test
void shouldResolveRecordComponentAfterNonTrivialGetterWrapper(@TempDir Path tempDir) throws IOException { void shouldResolveRecordComponentAfterNonTrivialGetterWrapper(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Event.java", """ writeJava(tempDir, "com/example/Event.java", """
@@ -278,7 +313,10 @@ class AccessorInliningDataFlowTest {
"""); """);
assertResolvedValue(tempDir, "value", "LITERAL"); assertResolvedValue(tempDir, "value", "LITERAL");
assertThat(accessorLookup(tempDir, "com.example.Runner.Service", "getEvent")).isEmpty(); assertThat(accessorLookup(tempDir, "com.example.Runner.Service", "getEvent"))
.isPresent()
.get()
.satisfies(summary -> assertThat(summary.kind()).isEqualTo(AccessorKind.CONSTANT_GETTER));
} }
@Test @Test

View File

@@ -78,6 +78,35 @@ class ConstantExtractorAccessorInliningTest {
assertThat(context.getAccessorIndex().lookup("com.example.Payload", "setEvent")).isPresent(); assertThat(context.getAccessorIndex().lookup("com.example.Payload", "setEvent")).isPresent();
} }
@Test
void resolveMethodReturnConstantShouldUseImplementationIndexedGetter(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/EventType.java", """
package com.example;
public interface EventType {
String getCode();
}
""");
writeJava(tempDir, "com/example/PayEvent.java", """
package com.example;
public class PayEvent implements EventType {
private String code = "PAY";
public String getCode() { return code; }
}
""");
context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(tempDir.toAbsolutePath().toString()));
context.scan(tempDir);
constantExtractor = new ConstantExtractor(context, context.getConstantResolver(), mi -> null);
constantExtractor.setConstructorAnalyzer(new ConstructorAnalyzer(context, context.getConstantResolver()));
List<String> constants = constantExtractor.resolveMethodReturnConstant(
"com.example.EventType", "getCode", 0, new HashSet<>(), null);
assertThat(constants).contains("PAY");
}
private static void writeJava(Path tempDir, String relativePath, String source) throws IOException { private static void writeJava(Path tempDir, String relativePath, String source) throws IOException {
Path file = tempDir.resolve(relativePath); Path file = tempDir.resolve(relativePath);
Files.createDirectories(file.getParent()); Files.createDirectories(file.getParent());

View File

@@ -79,6 +79,47 @@ class TrivialAccessorDetectorTest {
assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isPresent(); assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isPresent();
} }
@Test
void shouldDetectConstantGetter() {
MethodDeclaration md = method("""
public class Demo {
public String getEvent() { return "PAY"; }
}
""", 0);
Optional<AccessorSummary> summary = detector.detect("com.example.Demo", md, KNOWN_FIELDS);
assertThat(summary).isPresent();
assertThat(summary.get().kind()).isEqualTo(AccessorKind.CONSTANT_GETTER);
}
@Test
void shouldDetectEnumConstantGetter() {
MethodDeclaration md = method("""
public class Demo {
public OrderEvents getType() { return OrderEvents.PAY; }
}
enum OrderEvents { PAY }
""", 0);
Optional<AccessorSummary> summary = detector.detect("com.example.Demo", md, KNOWN_FIELDS);
assertThat(summary).isPresent();
assertThat(summary.get().kind()).isEqualTo(AccessorKind.CONSTANT_GETTER);
}
@Test
void shouldRejectComputedConstantGetter() {
MethodDeclaration md = method("""
public class Demo {
public String getEvent() { return build(); }
private String build() { return "X"; }
}
""", 0);
assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isEmpty();
}
@Test @Test
void shouldRejectLazyInitGetter() { void shouldRejectLazyInitGetter() {
MethodDeclaration md = method(""" MethodDeclaration md = method("""

View File

@@ -0,0 +1,341 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
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.ExpressionAccessClassifier;
import click.kamil.springstatemachineexporter.analysis.service.HeuristicCallGraphEngine;
import click.kamil.springstatemachineexporter.analysis.service.VariableTracer;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.TypeDeclaration;
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 PipelineRefactorTest {
@TempDir
Path tempDir;
CodebaseContext context;
ConstantExtractor constantExtractor;
ConstructorAnalyzer constructorAnalyzer;
AccessorResolver accessorResolver;
ExpressionAccessClassifier expressionAccessClassifier;
@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 variableTracer = new VariableTracer(context, constantResolver);
constantExtractor.setVariableTracer(variableTracer);
constantExtractor.setConstructorAnalyzer(constructorAnalyzer);
constructorAnalyzer.setVariableTracer(variableTracer);
constructorAnalyzer.setConstantExtractor(constantExtractor);
expressionAccessClassifier = new ExpressionAccessClassifier(context, variableTracer, constantResolver, expr -> null);
constantExtractor.setExpressionAccessClassifier(expressionAccessClassifier);
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 scanWorkspace() throws IOException {
context.scan(tempDir);
}
@Nested
class ResolutionBudgetTests {
@Test
void shouldUseSharedDefaults() {
ResolutionBudget budget = ResolutionBudget.defaults();
assertThat(budget.isMethodReturnExhausted(20)).isFalse();
assertThat(budget.isMethodReturnExhausted(21)).isTrue();
assertThat(budget.isUnwrapExhausted(5)).isFalse();
assertThat(budget.isUnwrapExhausted(6)).isTrue();
assertThat(budget.allowGlobalFieldScan()).isFalse();
}
@Test
void shouldEnableGlobalFieldScanExplicitly() {
assertThat(ResolutionBudget.withGlobalFieldScan().allowGlobalFieldScan()).isTrue();
}
}
@Nested
class LibraryUnwrapRegistryTests {
@Test
void shouldRecognizeReactiveReceiversAndFactoryMethods() {
assertThat(LibraryUnwrapRegistry.isUnwrapReceiverType("Mono")).isTrue();
assertThat(LibraryUnwrapRegistry.isUnwrapMethodName("just")).isTrue();
assertThat(LibraryUnwrapRegistry.shouldStopUnwrap("buildPayload")).isTrue();
assertThat(LibraryUnwrapRegistry.isEventSetterMethodName("eventType")).isTrue();
}
}
@Nested
class MethodInvocationUnwrapperTests {
@Test
void shouldPeelOptionalOfFactoryCall() throws IOException {
writeJava("com/example/Factory.java", """
package com.example;
public class Factory {
public static String build() {
return Optional.of("PAY").get();
}
}
""");
scanWorkspace();
TypeDeclaration td = context.getTypeDeclaration("com.example.Factory");
assertThat(td).isNotNull();
}
}
@Nested
class AccessorResolverTests {
@Test
void shouldResolveConstantGetterWithoutCic() throws IOException {
writeJava("com/example/Events.java", """
package com.example;
public enum Events { PAY, CANCEL }
""");
writeJava("com/example/EventHolder.java", """
package com.example;
public class EventHolder {
public Events getEvent() { return Events.PAY; }
}
""");
scanWorkspace();
List<String> resolved = accessorResolver.resolve(
"com.example.EventHolder",
"getEvent",
new AccessorResolver.ReceiverContext(null, null, null, false, null),
ResolutionBudget.defaults());
assertThat(resolved).contains("Events.PAY");
}
@Test
void shouldCacheRepeatedLookups() throws IOException {
writeJava("com/example/Events.java", """
package com.example;
public enum Events { PAY }
""");
writeJava("com/example/EventHolder.java", """
package com.example;
public class EventHolder {
public Events getEvent() { return Events.PAY; }
}
""");
scanWorkspace();
AccessorResolver.ReceiverContext receiverContext =
new AccessorResolver.ReceiverContext(null, null, null, false, null);
List<String> first = accessorResolver.resolve(
"com.example.EventHolder", "getEvent", receiverContext, ResolutionBudget.defaults());
List<String> second = accessorResolver.resolve(
"com.example.EventHolder", "getEvent", receiverContext, ResolutionBudget.defaults());
assertThat(first).contains("Events.PAY");
assertThat(second).isSameAs(first);
}
@Test
void shouldResolveGetterChainStepViaIndexBeforeReturnedCic() throws IOException {
writeJava("com/example/Payload.java", """
package com.example;
public class Payload {
public String getType() { return "PAY"; }
}
""");
writeJava("com/example/Inner.java", """
package com.example;
public class Inner {
private final Payload payload = new Payload();
public Payload getPayload() { return payload; }
}
""");
scanWorkspace();
AccessorResolver.GetterChainStep step = accessorResolver.resolveGetterChainStep(
"com.example.Inner", "getPayload", ResolutionBudget.defaults());
assertThat(step).isNotNull();
assertThat(step.typeFqn()).contains("Payload");
assertThat(step.cic()).isNotNull();
}
@Test
void shouldResolveFactoryBuildThenGetterWithoutCicDeadEnd() throws IOException {
writeJava("com/example/Events.java", """
package com.example;
public enum Events { PAY }
""");
writeJava("com/example/Order.java", """
package com.example;
public class Order {
public Events getEvent() { return Events.PAY; }
}
""");
writeJava("com/example/OrderFactory.java", """
package com.example;
public class OrderFactory {
public Order build() { return new Order(); }
}
""");
writeJava("com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
OrderFactory factory = new OrderFactory();
Order order = factory.build();
Events event = order.getEvent();
}
}
""");
scanWorkspace();
List<String> events = accessorResolver.resolve(
"com.example.Order",
"getEvent",
new AccessorResolver.ReceiverContext(
context.getTypeDeclaration("com.example.Order"),
null,
null,
false,
null),
ResolutionBudget.defaults());
assertThat(events).contains("Events.PAY");
}
@Test
void shouldWidenInterfaceGettersToAllImplementations() throws IOException {
writeJava("com/example/Events.java", """
package com.example;
public enum Events { ALPHA, BETA }
""");
writeJava("com/example/AlphaPayload.java", """
package com.example;
public class AlphaPayload implements Payload {
public Events type() { return Events.ALPHA; }
}
""");
writeJava("com/example/BetaPayload.java", """
package com.example;
public class BetaPayload implements Payload {
public Events type() { return Events.BETA; }
}
""");
writeJava("com/example/Payload.java", """
package com.example;
public interface Payload {
Events type();
}
""");
scanWorkspace();
List<String> resolved = accessorResolver.resolve(
"com.example.Payload",
"type",
new AccessorResolver.ReceiverContext(
context.getTypeDeclaration("com.example.Payload"),
null,
null,
false,
null),
ResolutionBudget.defaults());
assertThat(resolved).containsExactlyInAnyOrder("Events.ALPHA", "Events.BETA");
}
}
@Nested
class ExpressionAccessClassifierSuffixTests {
@Test
void shouldTreatRecordStyleSuffixesAsTraceable() {
assertThat(expressionAccessClassifier.isTraceableAccessorSuffix(".getEvent()")).isTrue();
assertThat(expressionAccessClassifier.isTraceableAccessorSuffix(".type()")).isTrue();
assertThat(expressionAccessClassifier.isTraceableAccessorSuffix(".event()")).isTrue();
assertThat(expressionAccessClassifier.isTraceableAccessorSuffix(".hashCode()")).isFalse();
}
}
@Nested
class ConstructorAnalyzerGlobalScanTests {
@Test
void shouldSkipGlobalScanByDefault() throws IOException {
writeJava("com/example/Unrelated.java", """
package com.example;
public class Unrelated {
private String sharedName = "SHOULD_NOT_MATCH";
}
""");
writeJava("com/example/Target.java", """
package com.example;
public class Target {
private String sharedName;
public Target() { this.sharedName = "LOCAL"; }
}
""");
scanWorkspace();
TypeDeclaration td = context.getTypeDeclaration("com.example.Target");
List<String> traced = constructorAnalyzer.traceFieldInConstructors(
td, "sharedName", context, new HashSet<>(), ResolutionBudget.defaults());
assertThat(traced).contains("LOCAL");
assertThat(traced).doesNotContain("SHOULD_NOT_MATCH");
}
@Test
void shouldAllowGlobalScanWhenExplicitlyEnabled() throws IOException {
writeJava("com/example/OnlyFieldOwner.java", """
package com.example;
public class OnlyFieldOwner {
private String orphanField = "GLOBAL";
}
""");
writeJava("com/example/Shell.java", """
package com.example;
public class Shell {
private String orphanField;
}
""");
scanWorkspace();
TypeDeclaration shell = context.getTypeDeclaration("com.example.Shell");
List<String> withoutGlobal = constructorAnalyzer.traceFieldInConstructors(
shell, "orphanField", context, new HashSet<>(), ResolutionBudget.defaults());
assertThat(withoutGlobal).isEmpty();
List<String> withGlobal = constructorAnalyzer.traceFieldInConstructors(
shell, "orphanField", context, new HashSet<>(), ResolutionBudget.withGlobalFieldScan());
assertThat(withGlobal).contains("GLOBAL");
}
}
@Nested
class ConstantResolverSharingTests {
@Test
void shouldUseSingleConstantResolverFromContext() {
assertThat(new HeuristicCallGraphEngine(context).getClass()).isNotNull();
ConstantResolver shared = context.getConstantResolver();
ConstantExtractor extractor = new ConstantExtractor(context, shared, mi -> null);
assertThat(extractor).isNotNull();
assertThat(shared).isSameAs(context.getConstantResolver());
}
}
}

View File

@@ -0,0 +1,414 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
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.Expression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import org.eclipse.jdt.core.dom.VariableDeclarationStatement;
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.List;
import java.util.Optional;
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Quirk-focused tests for accessor inlining edge cases: super dispatch, field-backed chains,
* inheritance, and look-alike patterns that must not be confused.
*/
class AccessorInliningEdgeCasesTest {
@Nested
class SuperMethodAccessorShortcut {
@Test
void shouldReadFieldDeclaredOnGrandparent(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Grand.java", """
package com.example;
public class Grand {
protected String token = "GRAND";
public String getToken() { return token; }
}
""");
writeJava(tempDir, "com/example/Child.java", """
package com.example;
public class Child extends Grand {
public String capture() {
return super.getToken();
}
}
""");
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
Child child = new Child();
String value = child.capture();
}
}
""");
assertResolved(tempDir, "value", "GRAND");
}
@Test
void shouldNotShortcutWhenSuperGetterIsNonTrivial(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Base.java", """
package com.example;
public class Base {
protected String event = "IGNORED";
public String getEvent() {
return event == null ? "NONE" : event;
}
}
""");
writeJava(tempDir, "com/example/Child.java", """
package com.example;
public class Child extends Base {
public String capture() {
return super.getEvent();
}
}
""");
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
public void run() {
Child child = new Child();
String value = child.capture();
}
}
""");
assertThat(accessorLookup(tempDir, "com.example.Base", "getEvent")).isEmpty();
}
@Test
void callGraphShouldPreferSuperFieldOverChildOverride(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
ChildController controller;
public void run() {
controller.process();
}
}
class ChildController extends ParentController {
@Override
public OrderEvent getEvent() { return OrderEvent.SHIP; }
public void process() {
StateMachine sm = new StateMachine();
sm.sendEvent(super.getEvent());
}
}
class ParentController {
private OrderEvent event = OrderEvent.PAY;
public OrderEvent getEvent() { return event; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, "com.example.ApiController", "run", "com.example.StateMachine");
assertPolyEvents(chain, "OrderEvent.PAY");
}
}
@Nested
class FieldBackedGetterChains {
@Test
void shouldResolveDeepFieldBackedChainWithoutHopLimit(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(Order order) {
dispatcher.fire(order);
}
}
class CentralDispatcher {
public void fire(Order order) {
StateMachine sm = new StateMachine();
sm.sendEvent(order.getPayload().getEvent().getCode());
}
}
class Order {
private Payload payload = new Payload();
public Payload getPayload() { return payload; }
}
class Payload {
private Event event = new Event();
public Event getEvent() { return event; }
}
class Event {
private OrderEvent code = OrderEvent.PAY;
public OrderEvent getCode() { return code; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, "com.example.ApiController", "pay", "com.example.StateMachine");
assertPolyEvents(chain, "OrderEvent.PAY");
}
@Test
void shouldResolveFieldInitializerDeclaredOnSuperclass(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(BaseWrapper wrapper) {
dispatcher.fire(wrapper);
}
}
class CentralDispatcher {
public void fire(BaseWrapper wrapper) {
StateMachine sm = new StateMachine();
sm.sendEvent(wrapper.getPayload().getType());
}
}
class BaseWrapper {
protected Payload payload = new Payload();
public Payload getPayload() { return payload; }
}
class Payload {
private OrderEvent type = OrderEvent.PAY;
public OrderEvent getType() { return type; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, "com.example.ApiController", "pay", "com.example.StateMachine");
assertPolyEvents(chain, "OrderEvent.PAY");
}
@Test
void shouldNotUseIndexedFieldWhenInnerGetterReturnsLiteral(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(EventWrapper wrapper) {
dispatcher.fire(wrapper);
}
}
class CentralDispatcher {
public void fire(EventWrapper wrapper) {
StateMachine sm = new StateMachine();
sm.sendEvent(wrapper.getEvent().getType());
}
}
class EventWrapper {
private RichEvent event = new RichEvent();
public RichEvent getEvent() { return event; }
}
class RichEvent {
private OrderEvent type = OrderEvent.PAY;
public OrderEvent getType() {
return OrderEvent.SHIP;
}
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, "com.example.ApiController", "pay", "com.example.StateMachine");
assertPolyEvents(chain, "OrderEvent.SHIP");
}
@Test
void shouldDistinguishFieldBackedChainFromLiteralReturnChain(@TempDir Path tempDir) throws Exception {
String literalSource = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(EventWrapper wrapper) {
dispatcher.fire(wrapper);
}
}
class CentralDispatcher {
public void fire(EventWrapper wrapper) {
StateMachine sm = new StateMachine();
sm.sendEvent(wrapper.getEvent().getType());
}
}
class EventWrapper {
public RichEvent getEvent() { return new RichEvent(); }
}
class RichEvent {
public OrderEvent getType() { return OrderEvent.PAY; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
Path literalDir = tempDir.resolve("literal");
Files.createDirectories(literalDir);
CodebaseContext literalContext = scanSource(literalSource, literalDir);
CallChain literalChain = resolveChain(
literalContext, "com.example.ApiController", "pay", "com.example.StateMachine");
assertPolyEvents(literalChain, "OrderEvent.PAY");
String fieldSource = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(EventWrapper wrapper) {
dispatcher.fire(wrapper);
}
}
class CentralDispatcher {
public void fire(EventWrapper wrapper) {
StateMachine sm = new StateMachine();
sm.sendEvent(wrapper.getEvent().getType());
}
}
class EventWrapper {
private RichEvent event = new RichEvent();
public RichEvent getEvent() { return event; }
}
class RichEvent {
private OrderEvent type = OrderEvent.PAY;
public OrderEvent getType() { return type; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
Path fieldDir = tempDir.resolve("field");
Files.createDirectories(fieldDir);
CodebaseContext fieldContext = scanSource(fieldSource, fieldDir);
CallChain fieldChain = resolveChain(
fieldContext, "com.example.ApiController", "pay", "com.example.StateMachine");
assertPolyEvents(fieldChain, "OrderEvent.PAY");
assertLinkedEvent(
linkEndpointToTransition(fieldChain, "com.example.OrderStateMachineConfig",
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.PAY");
}
}
@Nested
class LookAlikePatterns {
@Test
void shouldNotTreatListGetAsBeanAccessorInDataflow(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
import java.util.List;
public class Runner {
public void run() {
List<String> events = List.of("A", "B");
String value = events.get(0);
}
}
""");
CodebaseContext context = scan(tempDir);
assertThat(context.getAccessorIndex().lookup("java.util.List", "get")).isEmpty();
DataFlowModel model = new JdtDataFlowModel(context);
Expression initializer = findInitializer(context, "run", "value");
assertThat(model.getReachingDefinitions(initializer)).isNotEmpty();
}
@Test
void shouldNotIndexBareGetMethod(@TempDir Path tempDir) throws IOException {
writeJava(tempDir, "com/example/Runner.java", """
package com.example;
public class Runner {
static class Box {
public String get() { return "X"; }
}
public void run() {
Box box = new Box();
String value = box.get();
}
}
""");
assertThat(accessorLookup(tempDir, "com.example.Runner.Box", "get")).isEmpty();
assertResolved(tempDir, "value", "X");
}
}
private static void assertResolved(Path tempDir, String variableName, String expected) throws IOException {
CodebaseContext context = scan(tempDir);
DataFlowModel model = new JdtDataFlowModel(context);
Expression initializer = findInitializer(context, "run", variableName);
String resolved = model.resolveValue(initializer, context);
assertThat(resolved).isEqualTo(expected);
}
private static Optional<AccessorSummary> accessorLookup(Path tempDir, String ownerFqn, String methodName)
throws IOException {
return scan(tempDir).getAccessorIndex().lookup(ownerFqn, methodName);
}
private static CodebaseContext scan(Path tempDir) throws IOException {
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(tempDir.toAbsolutePath().toString()));
context.scan(tempDir);
return context;
}
private static Expression findInitializer(CodebaseContext context, String methodName, String variableName) {
for (TypeDeclaration type : context.getTypeDeclarations()) {
Expression result = findInitializerInType(type, methodName, variableName);
if (result != null) {
return result;
}
}
throw new AssertionError("Initializer not found for " + variableName + " in " + methodName);
}
private static Expression findInitializerInType(TypeDeclaration type, String methodName, String variableName) {
for (MethodDeclaration method : type.getMethods()) {
if (!method.getName().getIdentifier().equals(methodName) || method.getBody() == null) {
continue;
}
for (Object statementObj : method.getBody().statements()) {
if (statementObj instanceof VariableDeclarationStatement statement) {
for (Object fragmentObj : statement.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment
&& fragment.getName().getIdentifier().equals(variableName)) {
return fragment.getInitializer();
}
}
}
}
}
for (TypeDeclaration nested : type.getTypes()) {
Expression result = findInitializerInType(nested, methodName, variableName);
if (result != null) {
return result;
}
}
return null;
}
private static void writeJava(Path tempDir, String relativePath, String source) throws IOException {
Path file = tempDir.resolve(relativePath);
Files.createDirectories(file.getParent());
Files.writeString(file, source);
}
}

View File

@@ -417,4 +417,46 @@ class CentralDispatcherResolutionTest {
transition("PAID", "SHIPPED", "OrderEvent.SHIP")), transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.PAY"); "OrderEvent.PAY");
} }
/**
* Chained field-backed trivial getters ({@code return this.field}) through a central dispatcher.
*/
@Test
void fieldBackedGetterChainShouldResolveThroughCentralDispatcher(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay(EventWrapper wrapper) {
dispatcher.fireOrderEvent(wrapper);
}
}
class CentralDispatcher {
public void fireOrderEvent(EventWrapper wrapper) {
StateMachine sm = new StateMachine();
sm.sendEvent(wrapper.getEvent().getType());
}
}
class EventWrapper {
private RichEvent event = new RichEvent();
public RichEvent getEvent() { return event; }
}
class RichEvent {
private OrderEvent type = OrderEvent.PAY;
public OrderEvent getType() { return type; }
}
enum OrderEvent { PAY, SHIP }
class StateMachine { public void sendEvent(OrderEvent e) {} }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain chain = resolveChain(context, CONTROLLER, "pay", STATE_MACHINE);
assertPolyEvents(chain, "OrderEvent.PAY");
assertLinkedEvent(
linkEndpointToTransition(chain, MACHINE_CONFIG,
transition("NEW", "PAID", "OrderEvent.PAY"),
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
"OrderEvent.PAY");
}
} }