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:
@@ -30,6 +30,10 @@ public final class AccessorFieldResolver {
|
||||
if (accessor == null || !accessor.isGetter() || context == null) {
|
||||
return List.of();
|
||||
}
|
||||
if (accessor.kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER
|
||||
|| accessor.fieldName() == null) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
TypeDeclaration typeDeclaration = contextCu != null
|
||||
? context.getTypeDeclaration(accessor.declaringFqn(), contextCu)
|
||||
|
||||
@@ -3,6 +3,7 @@ package click.kamil.springstatemachineexporter.analysis.index;
|
||||
public enum AccessorKind {
|
||||
GETTER,
|
||||
BOOLEAN_GETTER,
|
||||
CONSTANT_GETTER,
|
||||
RECORD_COMPONENT,
|
||||
SETTER,
|
||||
FLUENT_SETTER
|
||||
|
||||
@@ -12,6 +12,7 @@ public record AccessorSummary(
|
||||
public boolean isGetter() {
|
||||
return kind == AccessorKind.GETTER
|
||||
|| kind == AccessorKind.BOOLEAN_GETTER
|
||||
|| kind == AccessorKind.CONSTANT_GETTER
|
||||
|| kind == AccessorKind.RECORD_COMPONENT;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,11 @@ public class TrivialAccessorDetector {
|
||||
|
||||
Optional<String> getterField = propertyNameForGetter(methodName, method);
|
||||
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);
|
||||
@@ -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(
|
||||
String ownerFqn,
|
||||
MethodDeclaration method,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@ package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
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.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
@@ -23,6 +27,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
protected final ConstantExtractor constantExtractor;
|
||||
protected final ConstructorAnalyzer constructorAnalyzer;
|
||||
protected final ExpressionAccessClassifier expressionAccessClassifier;
|
||||
protected final AccessorResolver accessorResolver;
|
||||
protected final FieldInitializerFinder fieldInitializerFinder;
|
||||
private final Map<String, ASTNode> parsedNodeCache = new HashMap<>();
|
||||
|
||||
private ASTNode parseExpressionString(String expr) {
|
||||
@@ -65,7 +71,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
|
||||
public AbstractCallGraphEngine(CodebaseContext context) {
|
||||
this.context = context;
|
||||
this.constantResolver = new ConstantResolver();
|
||||
this.constantResolver = context.getConstantResolver();
|
||||
|
||||
this.typeResolver = new TypeResolver(context);
|
||||
this.pathFinder = new CallGraphPathFinder(context);
|
||||
@@ -82,6 +88,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
this.expressionAccessClassifier = new ExpressionAccessClassifier(
|
||||
context, variableTracer, constantResolver, this::parseExpressionString);
|
||||
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) {
|
||||
@@ -297,94 +306,35 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
|
||||
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);
|
||||
if (chainedGetterEvents != null && !chainedGetterEvents.isEmpty()) {
|
||||
log.debug("Early return (chained getter): getterEvents = {}", chainedGetterEvents);
|
||||
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(chainedGetterEvents)
|
||||
.constraint(tp.getConstraint())
|
||||
.stateTypeFqn(tp.getStateTypeFqn())
|
||||
.eventTypeFqn(tp.getEventTypeFqn())
|
||||
.external(tp.isExternal())
|
||||
.build();
|
||||
return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, chainedGetterEvents);
|
||||
}
|
||||
|
||||
if (methodSuffix.startsWith(".get") || methodSuffix.equals(".type") || methodSuffix.equals(".event")
|
||||
|| methodSuffix.equals(".type()") || methodSuffix.equals(".event()")) {
|
||||
String localMethodName = methodSuffix.substring(1).replace("()", "");
|
||||
String scopeForVar = resolveScopeMethodForVariable(currentParamName, entryMethod, path);
|
||||
Expression localSetterExpr = variableTracer.traceLocalSetter(scopeForVar, currentParamName, localMethodName);
|
||||
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();
|
||||
}
|
||||
if (expressionAccessClassifier.isTraceableAccessorSuffix(methodSuffix)) {
|
||||
List<String> setterEvents = resolveSetterEventsFromSuffix(currentParamName, methodSuffix, entryMethod, path);
|
||||
if (!setterEvents.isEmpty()) {
|
||||
log.debug("Early return (scoped setter): setterEvents = {}", setterEvents);
|
||||
return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, setterEvents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int entryParamIndex = typeResolver.getParameterIndex(entryMethod, currentParamName);
|
||||
if (entryParamIndex < 0) {
|
||||
if (methodSuffix.startsWith(".get") || methodSuffix.equals(".type") || methodSuffix.equals(".event") || methodSuffix.equals(".type()") || methodSuffix.equals(".event()")) {
|
||||
String localMethodName = methodSuffix.substring(1).replace("()", "");
|
||||
String scopeForVar = resolveScopeMethodForVariable(currentParamName, entryMethod, path);
|
||||
Expression localSetterExpr = variableTracer.traceLocalSetter(scopeForVar, currentParamName, localMethodName);
|
||||
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 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();
|
||||
}
|
||||
if (methodSuffix != null && expressionAccessClassifier.isTraceableAccessorSuffix(methodSuffix)) {
|
||||
List<String> setterEvents = resolveSetterEventsFromSuffix(currentParamName, methodSuffix, entryMethod, path);
|
||||
if (!setterEvents.isEmpty()) {
|
||||
log.debug("Early return 1: setterEvents = {}", setterEvents);
|
||||
return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, setterEvents);
|
||||
}
|
||||
}
|
||||
|
||||
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("(")));
|
||||
if (hasGetterOnReceiver) {
|
||||
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
|
||||
* 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) {
|
||||
if (expr instanceof SimpleName sn) {
|
||||
String type = variableTracer.getVariableDeclaredType(entryMethod, sn.getIdentifier());
|
||||
@@ -1187,10 +1174,15 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
|
||||
String getterName = mi.getName().getIdentifier();
|
||||
String simpleType = simplifyTypeName(base.typeFqn());
|
||||
ClassInstanceCreation nextCic = base.cic();
|
||||
if (nextCic == null) {
|
||||
nextCic = constructorAnalyzer.findReturnedCIC(simpleType, getterName, context);
|
||||
String ownerFqn = resolveOwnerFqn(simpleType, base.typeFqn());
|
||||
|
||||
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) {
|
||||
return new GetterChainReceiver(
|
||||
click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(nextCic.getType()),
|
||||
@@ -1210,6 +1202,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
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) {
|
||||
if (typeFqn == null) {
|
||||
return null;
|
||||
@@ -1232,6 +1232,31 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
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) {
|
||||
ASTNode exprNode = parseExpressionString(resolvedValue);
|
||||
while (exprNode instanceof ParenthesizedExpression pe) {
|
||||
@@ -1251,12 +1276,31 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
|
||||
if (exprNode instanceof SuperMethodInvocation smi) {
|
||||
String getterName = smi.getName().getIdentifier();
|
||||
int lastDot = entryMethod.lastIndexOf('.');
|
||||
String className = lastDot > 0 ? entryMethod.substring(0, lastDot) : entryMethod;
|
||||
String className = resolveClassForSuperCall(entryMethod, path);
|
||||
TypeDeclaration currentTd = context.getTypeDeclaration(className);
|
||||
if (currentTd != null) {
|
||||
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) {
|
||||
String getterName = mi.getName().getIdentifier();
|
||||
@@ -1301,9 +1345,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
if (currentTd != null) {
|
||||
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);
|
||||
receiverType = variableTracer.getVariableDeclaredType(callerMethod, receiverName);
|
||||
}
|
||||
@@ -1350,53 +1399,55 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
cic = (ClassInstanceCreation) initExpr;
|
||||
}
|
||||
}
|
||||
if (cic == null) {
|
||||
return null;
|
||||
}
|
||||
MethodDeclaration getter = null;
|
||||
if (cic.getAnonymousClassDeclaration() != null) {
|
||||
for (Object declObj : cic.getAnonymousClassDeclaration().bodyDeclarations()) {
|
||||
if (declObj instanceof MethodDeclaration md && md.getName().getIdentifier().equals(getterName)) {
|
||||
getter = md;
|
||||
break;
|
||||
}
|
||||
boolean anonymousGetter = false;
|
||||
if (cic == null) {
|
||||
String ownerFqn = context.getFqn(td);
|
||||
List<String> resolvedWithoutCic = accessorResolver.resolve(
|
||||
ownerFqn,
|
||||
getterName,
|
||||
new AccessorResolver.ReceiverContext(td, null, contextCu, false, fieldValues),
|
||||
ResolutionBudget.defaults());
|
||||
if (!resolvedWithoutCic.isEmpty()) {
|
||||
return resolvedWithoutCic;
|
||||
}
|
||||
}
|
||||
if (getter == null) {
|
||||
getter = context.findMethodDeclaration(td, getterName, true);
|
||||
}
|
||||
if ((getterName.startsWith("get") && getterName.length() > 3)
|
||||
|| (getterName.startsWith("is") && getterName.length() > 2)
|
||||
|| context.getAccessorIndex().lookup(context.getFqn(td), getterName).isPresent()) {
|
||||
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;
|
||||
} else {
|
||||
if (cic.getAnonymousClassDeclaration() != null) {
|
||||
for (Object declObj : cic.getAnonymousClassDeclaration().bodyDeclarations()) {
|
||||
if (declObj instanceof MethodDeclaration md && md.getName().getIdentifier().equals(getterName)) {
|
||||
getter = md;
|
||||
anonymousGetter = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
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) {
|
||||
return unwrapMethodInvocation(mi, depth, null);
|
||||
}
|
||||
|
||||
protected Expression unwrapMethodInvocation(MethodInvocation mi, int depth, String methodFqn) {
|
||||
if (depth > 5) return mi;
|
||||
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;
|
||||
return MethodInvocationUnwrapper.unwrap(mi, depth, methodFqn, context, miArg -> {
|
||||
if (methodFqn == null || !methodFqn.contains(".")) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
if (methodFqn != null && methodFqn.contains(".")) {
|
||||
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
String currentClass = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||
if (miArg.getExpression() == null || miArg.getExpression().toString().equals("this")) {
|
||||
return currentClass + "." + miArg.getName().getIdentifier();
|
||||
}
|
||||
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")) {
|
||||
return mi;
|
||||
}
|
||||
|
||||
Expression arg = (Expression) mi.arguments().get(0);
|
||||
if (arg instanceof MethodInvocation innerMi) {
|
||||
return unwrapMethodInvocation(innerMi, depth + 1, methodFqn);
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
return mi;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
public List<String> resolveClassConstantReturns(String className, CodebaseContext context, CompilationUnit contextCu) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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.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 lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
@@ -17,6 +20,7 @@ public class ConstantExtractor {
|
||||
private VariableTracer variableTracer;
|
||||
private ConstructorAnalyzer constructorAnalyzer;
|
||||
private ExpressionAccessClassifier expressionAccessClassifier;
|
||||
private AccessorResolver accessorResolver;
|
||||
|
||||
public ConstantExtractor(CodebaseContext context, ConstantResolver constantResolver, Function<MethodInvocation, String> calledMethodResolver) {
|
||||
this.context = context;
|
||||
@@ -36,6 +40,10 @@ public class ConstantExtractor {
|
||||
this.expressionAccessClassifier = expressionAccessClassifier;
|
||||
}
|
||||
|
||||
public void setAccessorResolver(AccessorResolver accessorResolver) {
|
||||
this.accessorResolver = accessorResolver;
|
||||
}
|
||||
|
||||
public void extractConstantsFromExpression(Expression expr, List<String> constants) {
|
||||
if (constantResolver != null) {
|
||||
String resolved = constantResolver.resolve(expr, context);
|
||||
@@ -98,8 +106,7 @@ public class ConstantExtractor {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (methodName.equals("of") || methodName.equals("ofEntries") || methodName.equals("asList") || methodName.equals("entry") ||
|
||||
methodName.equals("just") || methodName.equals("withPayload") || methodName.equals("success")) {
|
||||
if (LibraryUnwrapRegistry.isUnwrapMethodName(methodName)) {
|
||||
for (Object argObj : mi.arguments()) {
|
||||
extractConstantsFromExpression((Expression) argObj, constants);
|
||||
}
|
||||
@@ -210,9 +217,9 @@ public class ConstantExtractor {
|
||||
Expression currentMi = mi;
|
||||
while (currentMi instanceof MethodInvocation chainMi) {
|
||||
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);
|
||||
} 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);
|
||||
}
|
||||
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) {
|
||||
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("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);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
@@ -289,8 +309,25 @@ public class ConstantExtractor {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
Optional<AccessorSummary> indexedAccessor = context.getAccessorIndex().lookup(className, methodName);
|
||||
if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) {
|
||||
Optional<AccessorSummary> indexedAccessor = lookupIndexedGetter(className, methodName, budget);
|
||||
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(
|
||||
indexedAccessor.get(), contextCu, visited);
|
||||
if (!indexedConstants.isEmpty()) {
|
||||
@@ -300,11 +337,6 @@ public class ConstantExtractor {
|
||||
}
|
||||
|
||||
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) {
|
||||
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
|
||||
if (md != null && md.getBody() != null) {
|
||||
@@ -387,11 +419,12 @@ public class ConstantExtractor {
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
} else if (widenToImplementations) {
|
||||
List<String> impls = context.getImplementations(className);
|
||||
if (impls != null) {
|
||||
for (String implName : impls) {
|
||||
List<String> delegationResult = resolveMethodReturnConstant(implName, methodName, depth + 1, visited, contextCu);
|
||||
List<String> delegationResult = resolveMethodReturnConstant(
|
||||
implName, methodName, depth + 1, visited, contextCu, budget);
|
||||
constants.addAll(delegationResult);
|
||||
}
|
||||
}
|
||||
@@ -416,6 +449,75 @@ public class ConstantExtractor {
|
||||
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) {
|
||||
return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
import java.util.*;
|
||||
@@ -33,6 +34,15 @@ public class ConstructorAnalyzer {
|
||||
}
|
||||
|
||||
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();
|
||||
String fqn = context.getFqn(td);
|
||||
if (fqn == null) return Collections.emptyList();
|
||||
@@ -154,7 +164,7 @@ public class ConstructorAnalyzer {
|
||||
if (superFqn != null && !superFqn.equals("java.lang.Object")) {
|
||||
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
|
||||
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) {
|
||||
TypeDeclaration implTd = context.getTypeDeclaration(implFqn);
|
||||
if (implTd != null) {
|
||||
results.addAll(traceFieldInConstructors(implTd, fieldName, context, visited));
|
||||
results.addAll(traceFieldInConstructors(implTd, fieldName, context, visited, budget));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (results.isEmpty()) {
|
||||
// Global Fallback: Search all parsed classes in the workspace
|
||||
if (results.isEmpty() && budget != null && budget.allowGlobalFieldScan()) {
|
||||
// Global Fallback: Search all parsed classes in the workspace (explicit opt-in only)
|
||||
Collection<TypeDeclaration> allTypes = context.getTypeDeclarations();
|
||||
if (allTypes != null) {
|
||||
int matchedCount = 0;
|
||||
for (TypeDeclaration typeDecl : allTypes) {
|
||||
if (typeDecl == null) continue;
|
||||
if (context.hasField(typeDecl, fieldName)) {
|
||||
results.addAll(traceFieldInConstructors(typeDecl, fieldName, context, visited));
|
||||
results.addAll(traceFieldInConstructors(typeDecl, fieldName, context, visited, budget));
|
||||
matchedCount++;
|
||||
if (matchedCount >= 10) {
|
||||
if (matchedCount >= budget.globalFieldScanLimit()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -465,7 +475,7 @@ public class ConstructorAnalyzer {
|
||||
private ClassInstanceCreation findReturnedCIC(
|
||||
String className, String methodName, CodebaseContext context,
|
||||
Set<String> visited, int depth) {
|
||||
if (depth > 50 || !visited.add(className + "#" + methodName)) {
|
||||
if (ResolutionBudget.defaults().isReturnedCicExhausted(depth) || !visited.add(className + "#" + methodName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,28 @@ public final class ExpressionAccessClassifier {
|
||||
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) {
|
||||
if (constantResolver == null || context == null || receiver == null) {
|
||||
return false;
|
||||
|
||||
@@ -21,7 +21,7 @@ import java.util.Map;
|
||||
public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
|
||||
private final CodebaseContext context;
|
||||
private final Path rootDir;
|
||||
private final ConstantResolver constantResolver = new ConstantResolver();
|
||||
private final ConstantResolver constantResolver;
|
||||
private final GenericEventDetector eventDetector;
|
||||
private final SpringMvcDetector mvcDetector;
|
||||
private final MessagingDetector messagingDetector;
|
||||
@@ -35,6 +35,7 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
|
||||
public JdtIntelligenceProvider(CodebaseContext context, Path rootDir) {
|
||||
this.context = context;
|
||||
this.rootDir = rootDir;
|
||||
this.constantResolver = context.getConstantResolver();
|
||||
this.eventDetector = new GenericEventDetector(context, constantResolver, context.getLibraryHints());
|
||||
this.mvcDetector = new SpringMvcDetector(context, constantResolver);
|
||||
this.messagingDetector = new MessagingDetector(context);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
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.index.AccessorSummary;
|
||||
import click.kamil.springstatemachineexporter.analysis.index.TrivialAccessorDetector;
|
||||
@@ -720,62 +722,7 @@ public class VariableTracer {
|
||||
}
|
||||
|
||||
private Expression unwrapMethodInvocation(MethodInvocation mi, int depth, String methodFqn) {
|
||||
if (depth > 5) return mi;
|
||||
|
||||
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;
|
||||
return MethodInvocationUnwrapper.unwrap(mi, depth, methodFqn, context, miArg -> resolveTargetMethodFqn(miArg, methodFqn));
|
||||
}
|
||||
|
||||
private String resolveTargetMethodFqn(MethodInvocation mi, String currentMethodFqn) {
|
||||
@@ -797,33 +744,4 @@ public class VariableTracer {
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package click.kamil.springstatemachineexporter.ast.common;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.index.AccessorInlining;
|
||||
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
import java.util.*;
|
||||
|
||||
@@ -62,7 +63,7 @@ public class JdtDataFlowModel implements DataFlowModel {
|
||||
Map<IVariableBinding, Expression> paramBindings,
|
||||
Map<IVariableBinding, Expression> instanceFieldBindings,
|
||||
int depth) {
|
||||
if (expr == null || depth > 50 || !visited.add(expr)) {
|
||||
if (expr == null || ResolutionBudget.defaults().isDataflowExhausted(depth) || !visited.add(expr)) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@@ -269,6 +270,19 @@ public class JdtDataFlowModel implements DataFlowModel {
|
||||
// Handle Super Method Invocations
|
||||
if (expr instanceof SuperMethodInvocation smi) {
|
||||
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) {
|
||||
MethodDeclaration md = findMethodDeclaration(mb);
|
||||
if (md != null && md.getBody() != null) {
|
||||
@@ -309,7 +323,7 @@ public class JdtDataFlowModel implements DataFlowModel {
|
||||
Map<IVariableBinding, Expression> paramBindings,
|
||||
Map<IVariableBinding, Expression> instanceFieldBindings,
|
||||
int depth) {
|
||||
if (depth > 25) {
|
||||
if (ResolutionBudget.defaults().isAccessorInlineExhausted(depth)) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@@ -359,6 +373,10 @@ public class JdtDataFlowModel implements DataFlowModel {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
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(
|
||||
String declaringFqn,
|
||||
String methodName,
|
||||
@@ -375,9 +421,6 @@ public class JdtDataFlowModel implements DataFlowModel {
|
||||
if (concreteFqn == null || concreteFqn.equals(declaringFqn)) {
|
||||
continue;
|
||||
}
|
||||
if (context.getAccessorIndex().lookup(concreteFqn, methodName).isPresent()) {
|
||||
continue;
|
||||
}
|
||||
TypeDeclaration concreteType = context.getTypeDeclaration(concreteFqn);
|
||||
if (concreteType != null && context.hasMethod(concreteType, methodName)) {
|
||||
return true;
|
||||
@@ -864,7 +907,7 @@ public class JdtDataFlowModel implements DataFlowModel {
|
||||
Map<IVariableBinding, Expression> callerParamValues,
|
||||
Map<IVariableBinding, Expression> fieldBindings,
|
||||
int depth) {
|
||||
if (cb == null || depth > 10) return;
|
||||
if (cb == null || ResolutionBudget.defaults().isConstructorExhausted(depth)) return;
|
||||
MethodDeclaration cd = findMethodDeclaration(cb);
|
||||
if (cd == null || cd.getBody() == null) return;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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.DataFlowModel;
|
||||
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
|
||||
@@ -98,6 +99,40 @@ class AccessorInliningDataFlowTest {
|
||||
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
|
||||
void shouldResolveRecordComponentAfterNonTrivialGetterWrapper(@TempDir Path tempDir) throws IOException {
|
||||
writeJava(tempDir, "com/example/Event.java", """
|
||||
@@ -278,7 +313,10 @@ class AccessorInliningDataFlowTest {
|
||||
""");
|
||||
|
||||
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
|
||||
|
||||
@@ -78,6 +78,35 @@ class ConstantExtractorAccessorInliningTest {
|
||||
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 {
|
||||
Path file = tempDir.resolve(relativePath);
|
||||
Files.createDirectories(file.getParent());
|
||||
|
||||
@@ -79,6 +79,47 @@ class TrivialAccessorDetectorTest {
|
||||
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
|
||||
void shouldRejectLazyInitGetter() {
|
||||
MethodDeclaration md = method("""
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -417,4 +417,46 @@ class CentralDispatcherResolutionTest {
|
||||
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
|
||||
"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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user