From a108d8cc6ef0825f780c19afa5a6a5f11c41e360 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Sat, 11 Jul 2026 20:12:22 +0200 Subject: [PATCH] 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 --- .../analysis/index/AccessorFieldResolver.java | 4 + .../analysis/index/AccessorKind.java | 1 + .../analysis/index/AccessorSummary.java | 1 + .../index/TrivialAccessorDetector.java | 51 ++- .../analysis/pipeline/AccessorResolver.java | 416 ++++++++++++++++++ .../pipeline/FieldInitializerFinder.java | 94 ++++ .../pipeline/LibraryUnwrapRegistry.java | 59 +++ .../pipeline/MethodInvocationUnwrapper.java | 136 ++++++ .../analysis/pipeline/ResolutionBudget.java | 115 +++++ .../service/AbstractCallGraphEngine.java | 386 ++++++++-------- .../analysis/service/ConstantExtractor.java | 132 +++++- .../analysis/service/ConstructorAnalyzer.java | 24 +- .../service/ExpressionAccessClassifier.java | 22 + .../service/JdtIntelligenceProvider.java | 3 +- .../analysis/service/VariableTracer.java | 88 +--- .../ast/common/JdtDataFlowModel.java | 55 ++- .../index/AccessorInliningDataFlowTest.java | 40 +- ...ConstantExtractorAccessorInliningTest.java | 29 ++ .../index/TrivialAccessorDetectorTest.java | 41 ++ .../pipeline/PipelineRefactorTest.java | 341 ++++++++++++++ .../AccessorInliningEdgeCasesTest.java | 414 +++++++++++++++++ .../CentralDispatcherResolutionTest.java | 42 ++ 22 files changed, 2176 insertions(+), 318 deletions(-) create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/AccessorResolver.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/FieldInitializerFinder.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/LibraryUnwrapRegistry.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/MethodInvocationUnwrapper.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/ResolutionBudget.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/PipelineRefactorTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AccessorInliningEdgeCasesTest.java diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorFieldResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorFieldResolver.java index 527ff6c..d5ba741 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorFieldResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorFieldResolver.java @@ -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) diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorKind.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorKind.java index a4647bb..01e942e 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorKind.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorKind.java @@ -3,6 +3,7 @@ package click.kamil.springstatemachineexporter.analysis.index; public enum AccessorKind { GETTER, BOOLEAN_GETTER, + CONSTANT_GETTER, RECORD_COMPONENT, SETTER, FLUENT_SETTER diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorSummary.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorSummary.java index 00fa823..79b30e3 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorSummary.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorSummary.java @@ -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; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/TrivialAccessorDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/TrivialAccessorDetector.java index 4001933..bb41ff7 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/TrivialAccessorDetector.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/TrivialAccessorDetector.java @@ -27,7 +27,11 @@ public class TrivialAccessorDetector { Optional getterField = propertyNameForGetter(methodName, method); if (getterField.isPresent()) { - return detectGetter(ownerFqn, method, methodName, getterField.get(), fieldLookup); + Optional fieldGetter = detectGetter(ownerFqn, method, methodName, getterField.get(), fieldLookup); + if (fieldGetter.isPresent()) { + return fieldGetter; + } + return detectConstantGetter(ownerFqn, method, methodName); } Optional setterField = propertyNameForSetter(methodName); @@ -53,6 +57,51 @@ public class TrivialAccessorDetector { )); } + private Optional 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 detectGetter( String ownerFqn, MethodDeclaration method, diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/AccessorResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/AccessorResolver.java new file mode 100644 index 0000000..1ceb2ee --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/AccessorResolver.java @@ -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 fieldValues) { + } + + private final CodebaseContext context; + private final ConstantExtractor constantExtractor; + private final ConstructorAnalyzer constructorAnalyzer; + private final ConstantResolver constantResolver; + private final FieldInitializerFinder fieldInitializerFinder; + private final Map> 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 lookupIndexedGetter(String className, String methodName) { + if (className == null || className.isBlank()) { + return Optional.empty(); + } + Optional 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 implementations = context.getImplementations(className); + if (implementations == null) { + return Optional.empty(); + } + for (String implementation : implementations) { + Optional implAccessor = context.getAccessorIndex().lookup(implementation, methodName); + if (implAccessor.isPresent()) { + return implAccessor; + } + } + return Optional.empty(); + } + + public List resolve(String ownerFqn, String methodName, ReceiverContext receiverContext, ResolutionBudget budget) { + return resolve(ownerFqn, methodName, receiverContext, budget, new HashSet<>()); + } + + public List resolve( + String ownerFqn, + String methodName, + ReceiverContext receiverContext, + ResolutionBudget budget, + Set visited) { + if (ownerFqn == null || methodName == null) { + return List.of(); + } + if (budget == null) { + budget = ResolutionBudget.defaults(); + } + + String cacheKey = cacheKey(ownerFqn, methodName, receiverContext); + List cached = sessionCache.get(cacheKey); + if (cached != null) { + return cached; + } + + List resolved = resolveInternal(ownerFqn, methodName, receiverContext, budget, visited); + if (!resolved.isEmpty()) { + List 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 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 resolveInternal( + String ownerFqn, + String methodName, + ReceiverContext receiverContext, + ResolutionBudget budget, + Set 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 widened = new ArrayList<>(); + List 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 indexedAccessor = lookupIndexedGetter(ownerFqn, methodName); + if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) { + List indexed = resolveIndexedAccessor(indexedAccessor.get(), receiverContext, visited); + if (!indexed.isEmpty()) { + return indexed; + } + } + + if (receiverContext != null && receiverContext.ownerType() != null && receiverContext.cic() != null) { + List 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 deep = constantExtractor.resolveMethodReturnConstant( + ownerFqn, methodName, 0, visited, contextCu, budget); + if (!deep.isEmpty()) { + return deep; + } + } + return List.of(); + } + + private List resolveIndexedAccessor( + AccessorSummary accessor, + ReceiverContext receiverContext, + Set visited) { + if (accessor.kind() == AccessorKind.CONSTANT_GETTER) { + List 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 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 resolveWithCic( + String ownerFqn, + String methodName, + ReceiverContext receiverContext, + AccessorSummary indexedAccessor, + Set visited) { + TypeDeclaration td = receiverContext.ownerType(); + ClassInstanceCreation cic = receiverContext.cic(); + Map 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 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 indexedConstants = constantExtractor.resolveIndexedGetterFieldConstants( + indexedAccessor, receiverContext.contextCu(), visited); + if (!indexedConstants.isEmpty()) { + return indexedConstants; + } + } + } + return List.of(); + } + + private List 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 constants = new ArrayList<>(); + BiConsumer> 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 accessorOpt = lookupIndexedGetter(ownerFqn, getterName); + if (accessorOpt.isEmpty() || accessorOpt.get().fieldName() == null) { + return null; + } + return accessorOpt.get().fieldName(); + } + + private static List deduplicatePreservingOrder(List values) { + List 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; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/FieldInitializerFinder.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/FieldInitializerFinder.java new file mode 100644 index 0000000..ad1e547 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/FieldInitializerFinder.java @@ -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 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 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; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/LibraryUnwrapRegistry.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/LibraryUnwrapRegistry.java new file mode 100644 index 0000000..dddbbad --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/LibraryUnwrapRegistry.java @@ -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 UNWRAP_RECEIVER_TYPES = Set.of( + "Optional", "Stream", "List", "Set", "Arrays", "Objects", "Mono", "Flux"); + + private static final Set 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); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/MethodInvocationUnwrapper.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/MethodInvocationUnwrapper.java new file mode 100644 index 0000000..3af51fb --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/MethodInvocationUnwrapper.java @@ -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 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 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; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/ResolutionBudget.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/ResolutionBudget.java new file mode 100644 index 0000000..1a3d0d4 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/ResolutionBudget.java @@ -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; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java index c9ecbe2..c18b7e8 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java @@ -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 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 findChains(List entryPoints, List 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 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 setterEvents = new ArrayList<>(); - List 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 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 setterEvents = new ArrayList<>(); - List 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 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 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 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 resolveSetterEventsFromSuffix( + String currentParamName, + String methodSuffix, + String entryMethod, + List 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 setterEvents = new ArrayList<>(); + List 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 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 evaluateGetterOnReceiver(String resolvedValue, String methodSuffix, String entryMethod, List path, Map> 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 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 constants = constantExtractor.resolveMethodReturnConstant( + receiverType, getterName, 0, new HashSet<>(), contextCu); + if (!constants.isEmpty()) { + return constants; + } + } + List 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 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 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 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 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 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 resolveClassConstantReturns(String className, CodebaseContext context, CompilationUnit contextCu) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java index fb6fc45..f7a2d13 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java @@ -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 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 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 resolveMethodReturnConstant(String className, String methodName, int depth, Set visited, CompilationUnit contextCu) { + return resolveMethodReturnConstant(className, methodName, depth, visited, contextCu, ResolutionBudget.defaults()); + } + + public List resolveMethodReturnConstant( + String className, + String methodName, + int depth, + Set 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 indexedAccessor = context.getAccessorIndex().lookup(className, methodName); - if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) { + Optional 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 constantGetterValues = extractConstantGetterReturn( + indexedAccessor.get().ownerFqn(), methodName, contextCu, visited); + if (!constantGetterValues.isEmpty()) { + visited.remove(fqn); + return constantGetterValues; + } + } List indexedConstants = resolveIndexedGetterFieldConstants( indexedAccessor.get(), contextCu, visited); if (!indexedConstants.isEmpty()) { @@ -300,11 +337,6 @@ public class ConstantExtractor { } List 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 impls = context.getImplementations(className); if (impls != null) { for (String implName : impls) { - List delegationResult = resolveMethodReturnConstant(implName, methodName, depth + 1, visited, contextCu); + List delegationResult = resolveMethodReturnConstant( + implName, methodName, depth + 1, visited, contextCu, budget); constants.addAll(delegationResult); } } @@ -416,6 +449,75 @@ public class ConstantExtractor { this::extractConstantsFromExpression); } + public Optional lookupIndexedGetter(String className, String methodName) { + return lookupIndexedGetter(className, methodName, ResolutionBudget.defaults()); + } + + private Optional lookupIndexedGetter(String className, String methodName, ResolutionBudget budget) { + if (accessorResolver != null) { + return accessorResolver.lookupIndexedGetter(className, methodName); + } + if (className == null || className.isBlank()) { + return Optional.empty(); + } + Optional 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 implementations = context.getImplementations(className); + if (implementations == null) { + return Optional.empty(); + } + for (String implementation : implementations) { + Optional implAccessor = context.getAccessorIndex().lookup(implementation, methodName); + if (implAccessor.isPresent()) { + return implAccessor; + } + } + return Optional.empty(); + } + + private List extractConstantGetterReturn( + String ownerFqn, + String methodName, + CompilationUnit contextCu, + Set 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 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; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorAnalyzer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorAnalyzer.java index c85edaa..9a4655a 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorAnalyzer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorAnalyzer.java @@ -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 traceFieldInConstructors(TypeDeclaration td, String fieldName, CodebaseContext context, Set visited) { + return traceFieldInConstructors(td, fieldName, context, visited, ResolutionBudget.defaults()); + } + + public List traceFieldInConstructors( + TypeDeclaration td, + String fieldName, + CodebaseContext context, + Set 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 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 visited, int depth) { - if (depth > 50 || !visited.add(className + "#" + methodName)) { + if (ResolutionBudget.defaults().isReturnedCicExhausted(depth) || !visited.add(className + "#" + methodName)) { return null; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExpressionAccessClassifier.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExpressionAccessClassifier.java index 74d7ee0..35e982b 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExpressionAccessClassifier.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExpressionAccessClassifier.java @@ -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; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtIntelligenceProvider.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtIntelligenceProvider.java index e379f7b..a765f7f 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtIntelligenceProvider.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtIntelligenceProvider.java @@ -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); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java index 5bdfdd7..5648e4d 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java @@ -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 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; - } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java index 70b72cb..f0dc190 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java @@ -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 paramBindings, Map 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 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 paramBindings, Map 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 inlineConstantGetter( + AccessorSummary accessor, + Set visited, + Map paramBindings, + Map 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 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 callerParamValues, Map 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; diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/AccessorInliningDataFlowTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/AccessorInliningDataFlowTest.java index c58619b..e1424ab 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/AccessorInliningDataFlowTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/AccessorInliningDataFlowTest.java @@ -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 diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/ConstantExtractorAccessorInliningTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/ConstantExtractorAccessorInliningTest.java index 191c14b..a1cd89f 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/ConstantExtractorAccessorInliningTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/ConstantExtractorAccessorInliningTest.java @@ -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 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()); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/TrivialAccessorDetectorTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/TrivialAccessorDetectorTest.java index 430f838..d52cf3e 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/TrivialAccessorDetectorTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/TrivialAccessorDetectorTest.java @@ -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 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 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(""" diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/PipelineRefactorTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/PipelineRefactorTest.java new file mode 100644 index 0000000..e06052d --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/PipelineRefactorTest.java @@ -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 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 first = accessorResolver.resolve( + "com.example.EventHolder", "getEvent", receiverContext, ResolutionBudget.defaults()); + List 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 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 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 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 withoutGlobal = constructorAnalyzer.traceFieldInConstructors( + shell, "orphanField", context, new HashSet<>(), ResolutionBudget.defaults()); + assertThat(withoutGlobal).isEmpty(); + + List 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()); + } + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AccessorInliningEdgeCasesTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AccessorInliningEdgeCasesTest.java new file mode 100644 index 0000000..d6e8eb6 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AccessorInliningEdgeCasesTest.java @@ -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 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 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); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherResolutionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherResolutionTest.java index a88e8ed..210f743 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherResolutionTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CentralDispatcherResolutionTest.java @@ -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"); + } }