Fix export metadata scoping and call-chain resolution gaps.

Correct machine-scope filtering for domain paths and Spring type erasure, skip phantom REST interfaces, resolve custom message wrapper events, scope JDT call-chain lookup per machine, and wire scan-time analysis indexes with JDT/heuristic parity tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-12 09:43:50 +02:00
parent 7a5fc60ace
commit 5d3333d494
54 changed files with 3479 additions and 908 deletions

View File

@@ -72,7 +72,12 @@ public final class MachineScopeFilter {
String path = entryPoint.getMetadata() != null ? entryPoint.getMetadata().get("path") : null;
if (path != null) {
String pathDomain = inferDomainFromPath(path);
if (pathDomain != null && machineDomain != null && !domainsMatch(pathDomain, machineDomain)) {
if (pathDomain != null && machineDomain != null && isKnownDomainScopedMachine(machineDomain)
&& domainsMatch(pathDomain, machineDomain)) {
return true;
}
if (pathDomain != null && machineDomain != null && isKnownDomainScopedMachine(machineDomain)
&& !domainsMatch(pathDomain, machineDomain)) {
return false;
}
if (pathDomain == null && path.contains("{")) {
@@ -86,6 +91,11 @@ public final class MachineScopeFilter {
return ROUTING.isRoutedToCorrectMachine(probe, machineName, context);
}
private static boolean isKnownDomainScopedMachine(String machineDomain) {
String normalized = normalizeDomainKey(machineDomain);
return "ORDER".equals(normalized) || "DOCUMENT".equals(normalized) || "USER".equals(normalized);
}
private static String inferDomainFromPath(String path) {
String lower = path.toLowerCase(Locale.ROOT);
if (lower.contains("/orders") || lower.contains("order.")) {

View File

@@ -28,7 +28,10 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
if (isTypeMismatched(triggerEventFqn, machineEventFqn)) {
return false;
}
return false;
if (context != null && isSingleStateMachine(context)
&& (isErasedOrOpaqueType(triggerEventFqn) || isErasedOrOpaqueType(machineEventFqn))) {
return true;
}
}
if (triggerStateFqn != null && machineStateFqn != null) {
if (eraseGenerics(triggerStateFqn).equals(eraseGenerics(machineStateFqn))) {
@@ -37,6 +40,10 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
if (isTypeMismatched(triggerStateFqn, machineStateFqn)) {
return false; // Mismatch!
}
if (context != null && isSingleStateMachine(context)
&& (isErasedOrOpaqueType(triggerStateFqn) || isErasedOrOpaqueType(machineStateFqn))) {
return true;
}
}
}
}
@@ -319,6 +326,15 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
return !type1.equals(type2);
}
private boolean isErasedOrOpaqueType(String typeFqn) {
if (typeFqn == null) {
return false;
}
String erased = eraseGenerics(typeFqn);
return "java.lang.Object".equals(erased) || "Object".equals(erased)
|| "java.io.Serializable".equals(erased) || "Serializable".equals(erased);
}
private boolean isSingleStateMachine(CodebaseContext context) {
return countStateMachines(context) <= 1;
}

View File

@@ -0,0 +1,47 @@
package click.kamil.springstatemachineexporter.analysis.index;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public final class FieldTypeIndex {
private static final FieldTypeIndex EMPTY = new FieldTypeIndex(Map.of(), 0);
private final Map<String, Map<String, String>> byOwnerAndField;
private final int fieldCount;
public FieldTypeIndex(Map<String, Map<String, String>> byOwnerAndField, int fieldCount) {
this.byOwnerAndField = Collections.unmodifiableMap(deepCopy(byOwnerAndField));
this.fieldCount = fieldCount;
}
public static FieldTypeIndex empty() {
return EMPTY;
}
public Optional<String> lookup(String ownerFqn, String fieldName) {
if (ownerFqn == null || fieldName == null) {
return Optional.empty();
}
Map<String, String> fields = byOwnerAndField.get(ownerFqn);
if (fields == null) {
return Optional.empty();
}
return Optional.ofNullable(fields.get(fieldName));
}
public int fieldCount() {
return fieldCount;
}
private static Map<String, Map<String, String>> deepCopy(Map<String, Map<String, String>> source) {
Map<String, Map<String, String>> copy = new HashMap<>();
for (Map.Entry<String, Map<String, String>> entry : source.entrySet()) {
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
}
return copy;
}
}

View File

@@ -0,0 +1,98 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.Modifier;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
@Slf4j
public final class FieldTypeIndexBuilder {
private CodebaseContext context;
public FieldTypeIndex build(CodebaseContext context) {
this.context = context;
if (context == null) {
return FieldTypeIndex.empty();
}
Map<String, Map<String, String>> direct = new HashMap<>();
for (String ownerFqn : context.getIndexedTypeFqns()) {
TypeDeclaration typeDeclaration = context.getTypeDeclaration(ownerFqn);
if (typeDeclaration != null) {
indexDeclaredFields(ownerFqn, typeDeclaration, direct);
}
}
Map<String, Map<String, String>> effective = new HashMap<>();
int fieldCount = 0;
Map<String, Map<String, String>> cache = new HashMap<>();
for (String ownerFqn : direct.keySet()) {
Map<String, String> merged = mergeEffective(ownerFqn, direct, cache, new HashSet<>());
if (!merged.isEmpty()) {
effective.put(ownerFqn, merged);
fieldCount += merged.size();
}
}
log.info("Built field-type index: {} fields across {} types", fieldCount, effective.size());
this.context = null;
return new FieldTypeIndex(effective, fieldCount);
}
private void indexDeclaredFields(
String ownerFqn,
TypeDeclaration typeDeclaration,
Map<String, Map<String, String>> direct) {
Map<String, String> fields = direct.computeIfAbsent(ownerFqn, ignored -> new HashMap<>());
for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) {
String fieldType = fieldDeclaration.getType().toString();
for (Object fragmentObj : fieldDeclaration.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment) {
fields.put(fragment.getName().getIdentifier(), fieldType);
}
}
}
for (TypeDeclaration nested : typeDeclaration.getTypes()) {
indexDeclaredFields(context.getFqn(nested), nested, direct);
}
}
private Map<String, String> mergeEffective(
String ownerFqn,
Map<String, Map<String, String>> direct,
Map<String, Map<String, String>> cache,
Set<String> visiting) {
if (cache.containsKey(ownerFqn)) {
return cache.get(ownerFqn);
}
if (!visiting.add(ownerFqn)) {
return Map.of();
}
Map<String, String> merged = new HashMap<>();
TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn);
if (ownerType != null) {
String superFqn = context.getSuperclassFqn(ownerType);
if (superFqn != null && !"java.lang.Object".equals(superFqn)) {
merged.putAll(mergeEffective(superFqn, direct, cache, visiting));
}
}
Map<String, String> own = direct.get(ownerFqn);
if (own != null) {
merged.putAll(own);
}
visiting.remove(ownerFqn);
cache.put(ownerFqn, Map.copyOf(merged));
return merged;
}
}

View File

@@ -0,0 +1,14 @@
package click.kamil.springstatemachineexporter.analysis.index;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
/**
* Scan-time result for {@code owner.getX()} getter-chain hops: the next receiver type
* and optional {@code new ...()} field initializer discovered on the backing field.
*/
public record GetterChainEdge(
String ownerFqn,
String getterName,
String nextTypeFqn,
ClassInstanceCreation fieldInitializerCic) {
}

View File

@@ -0,0 +1,47 @@
package click.kamil.springstatemachineexporter.analysis.index;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public final class GetterChainIndex {
private static final GetterChainIndex EMPTY = new GetterChainIndex(Map.of(), 0);
private final Map<String, Map<String, GetterChainEdge>> byOwnerAndGetter;
private final int edgeCount;
public GetterChainIndex(Map<String, Map<String, GetterChainEdge>> byOwnerAndGetter, int edgeCount) {
this.byOwnerAndGetter = Collections.unmodifiableMap(deepCopy(byOwnerAndGetter));
this.edgeCount = edgeCount;
}
public static GetterChainIndex empty() {
return EMPTY;
}
public Optional<GetterChainEdge> lookup(String ownerFqn, String getterName) {
if (ownerFqn == null || getterName == null) {
return Optional.empty();
}
Map<String, GetterChainEdge> getters = byOwnerAndGetter.get(ownerFqn);
if (getters == null) {
return Optional.empty();
}
return Optional.ofNullable(getters.get(getterName));
}
public int edgeCount() {
return edgeCount;
}
private static Map<String, Map<String, GetterChainEdge>> deepCopy(
Map<String, Map<String, GetterChainEdge>> source) {
Map<String, Map<String, GetterChainEdge>> copy = new HashMap<>();
for (Map.Entry<String, Map<String, GetterChainEdge>> entry : source.entrySet()) {
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
}
return copy;
}
}

View File

@@ -0,0 +1,77 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.HashMap;
import java.util.Map;
@Slf4j
public final class GetterChainIndexBuilder {
public GetterChainIndex build(CodebaseContext context, AccessorIndex accessorIndex) {
if (context == null || accessorIndex == null) {
return GetterChainIndex.empty();
}
FieldInitializerFinder fieldInitializerFinder = new FieldInitializerFinder(context);
Map<String, Map<String, GetterChainEdge>> edges = new HashMap<>();
int edgeCount = 0;
for (Map.Entry<String, Map<String, AccessorSummary>> ownerEntry : accessorIndex.asMap().entrySet()) {
String ownerFqn = ownerEntry.getKey();
for (AccessorSummary accessor : ownerEntry.getValue().values()) {
if (!accessor.isGetter()) {
continue;
}
GetterChainEdge edge = buildEdge(context, fieldInitializerFinder, ownerFqn, accessor);
if (edge == null) {
continue;
}
edges.computeIfAbsent(ownerFqn, ignored -> new HashMap<>())
.put(accessor.methodName(), edge);
edgeCount++;
}
}
log.info("Built getter-chain index: {} edges across {} types", edgeCount, edges.size());
return new GetterChainIndex(edges, edgeCount);
}
private static GetterChainEdge buildEdge(
CodebaseContext context,
FieldInitializerFinder fieldInitializerFinder,
String ownerFqn,
AccessorSummary accessor) {
String nextType = null;
ClassInstanceCreation fieldInitializerCic = null;
if (accessor.fieldName() != null) {
fieldInitializerCic = fieldInitializerFinder.findFieldInitializerCic(ownerFqn, accessor.fieldName());
nextType = fieldInitializerFinder.resolveFieldTypeName(ownerFqn, accessor.fieldName());
if (fieldInitializerCic != null) {
nextType = AstUtils.extractSimpleTypeName(fieldInitializerCic.getType());
}
}
if (nextType == null) {
TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn);
MethodDeclaration getter = ownerType != null
? context.findMethodDeclaration(ownerType, accessor.methodName(), true)
: null;
if (getter != null && getter.getReturnType2() != null) {
nextType = getter.getReturnType2().toString();
}
}
if (nextType == null) {
return null;
}
return new GetterChainEdge(ownerFqn, accessor.methodName(), nextType, fieldInitializerCic);
}
}

View File

@@ -0,0 +1,54 @@
package click.kamil.springstatemachineexporter.analysis.index;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Scan-time constants for indexed getters backed by fields with literal or enum initializers.
*/
public final class IndexedFieldConstantsIndex {
private static final IndexedFieldConstantsIndex EMPTY = new IndexedFieldConstantsIndex(Map.of(), 0);
private final Map<String, Map<String, List<String>>> byOwnerAndMethod;
private final int entryCount;
public IndexedFieldConstantsIndex(Map<String, Map<String, List<String>>> byOwnerAndMethod, int entryCount) {
this.byOwnerAndMethod = Collections.unmodifiableMap(deepCopy(byOwnerAndMethod));
this.entryCount = entryCount;
}
public static IndexedFieldConstantsIndex empty() {
return EMPTY;
}
public Optional<List<String>> lookup(String ownerFqn, String methodName) {
if (ownerFqn == null || methodName == null) {
return Optional.empty();
}
Map<String, List<String>> methods = byOwnerAndMethod.get(ownerFqn);
if (methods == null) {
return Optional.empty();
}
List<String> constants = methods.get(methodName);
if (constants == null || constants.isEmpty()) {
return Optional.empty();
}
return Optional.of(constants);
}
public int entryCount() {
return entryCount;
}
private static Map<String, Map<String, List<String>>> deepCopy(Map<String, Map<String, List<String>>> source) {
Map<String, Map<String, List<String>>> copy = new HashMap<>();
for (Map.Entry<String, Map<String, List<String>>> entry : source.entrySet()) {
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
}
return copy;
}
}

View File

@@ -0,0 +1,41 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
public final class IndexedFieldConstantsIndexBuilder {
public IndexedFieldConstantsIndex build(CodebaseContext context, AccessorIndex accessorIndex) {
if (context == null || accessorIndex == null) {
return IndexedFieldConstantsIndex.empty();
}
Map<String, Map<String, List<String>>> entries = new HashMap<>();
int entryCount = 0;
for (Map.Entry<String, Map<String, AccessorSummary>> ownerEntry : accessorIndex.asMap().entrySet()) {
String ownerFqn = ownerEntry.getKey();
for (AccessorSummary accessor : ownerEntry.getValue().values()) {
if (!accessor.isGetter() || accessor.fieldName() == null) {
continue;
}
List<String> constants = ScanTimeAccessorConstants.resolve(
context, accessorIndex, ownerFqn, accessor.methodName());
if (constants.isEmpty()) {
continue;
}
entries.computeIfAbsent(ownerFqn, ignored -> new HashMap<>())
.put(accessor.methodName(), List.copyOf(constants));
entryCount++;
}
}
log.info("Built indexed field-constants index: {} entries across {} types", entryCount, entries.size());
return new IndexedFieldConstantsIndex(entries, entryCount);
}
}

View File

@@ -0,0 +1,16 @@
package click.kamil.springstatemachineexporter.analysis.index;
import java.util.List;
import java.util.Optional;
/**
* Scan-time summary of how a getter on an interface or abstract type resolves across
* its concrete implementations. Values are derived from static analysis only.
*/
public record PolymorphicAccessorEntry(
String ownerFqn,
String methodName,
Optional<AccessorSummary> representativeImplAccessor,
List<String> widenedReturnConstants,
boolean staticallyComplete) {
}

View File

@@ -0,0 +1,62 @@
package click.kamil.springstatemachineexporter.analysis.index;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public final class PolymorphicAccessorIndex {
private static final PolymorphicAccessorIndex EMPTY = new PolymorphicAccessorIndex(Map.of(), 0);
private final Map<String, Map<String, PolymorphicAccessorEntry>> byOwnerAndMethod;
private final int entryCount;
public PolymorphicAccessorIndex(
Map<String, Map<String, PolymorphicAccessorEntry>> byOwnerAndMethod,
int entryCount) {
this.byOwnerAndMethod = Collections.unmodifiableMap(deepCopy(byOwnerAndMethod));
this.entryCount = entryCount;
}
public static PolymorphicAccessorIndex empty() {
return EMPTY;
}
public Optional<PolymorphicAccessorEntry> lookup(String ownerFqn, String methodName) {
if (ownerFqn == null || methodName == null) {
return Optional.empty();
}
Map<String, PolymorphicAccessorEntry> methods = byOwnerAndMethod.get(ownerFqn);
if (methods == null) {
return Optional.empty();
}
return Optional.ofNullable(methods.get(methodName));
}
public Optional<AccessorSummary> representativeImplAccessor(String ownerFqn, String methodName) {
return lookup(ownerFqn, methodName)
.flatMap(PolymorphicAccessorEntry::representativeImplAccessor);
}
public Optional<List<String>> widenedReturnConstants(String ownerFqn, String methodName) {
return lookup(ownerFqn, methodName)
.filter(PolymorphicAccessorEntry::staticallyComplete)
.map(PolymorphicAccessorEntry::widenedReturnConstants)
.filter(values -> !values.isEmpty());
}
public int entryCount() {
return entryCount;
}
private static Map<String, Map<String, PolymorphicAccessorEntry>> deepCopy(
Map<String, Map<String, PolymorphicAccessorEntry>> source) {
Map<String, Map<String, PolymorphicAccessorEntry>> copy = new HashMap<>();
for (Map.Entry<String, Map<String, PolymorphicAccessorEntry>> entry : source.entrySet()) {
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
}
return copy;
}
}

View File

@@ -0,0 +1,156 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.Modifier;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@Slf4j
public final class PolymorphicAccessorIndexBuilder {
public PolymorphicAccessorIndex build(CodebaseContext context, AccessorIndex accessorIndex) {
if (context == null || accessorIndex == null) {
return PolymorphicAccessorIndex.empty();
}
Map<String, Map<String, PolymorphicAccessorEntry>> entries = new HashMap<>();
int entryCount = 0;
for (String typeFqn : context.getIndexedTypeFqns()) {
TypeDeclaration typeDeclaration = context.getTypeDeclaration(typeFqn);
if (!isPolymorphicType(typeDeclaration)) {
continue;
}
List<String> implementations = context.getImplementations(typeFqn);
if (implementations.isEmpty()) {
continue;
}
Set<String> methodNames = collectGetterMethodNames(context, accessorIndex, typeFqn, implementations);
for (String methodName : methodNames) {
PolymorphicAccessorEntry entry = buildEntry(
context, accessorIndex, typeFqn, methodName, implementations);
if (entry == null) {
continue;
}
entries.computeIfAbsent(typeFqn, ignored -> new HashMap<>()).put(methodName, entry);
entryCount++;
}
}
log.info("Built polymorphic accessor index: {} entries across {} types", entryCount, entries.size());
return new PolymorphicAccessorIndex(entries, entryCount);
}
private static PolymorphicAccessorEntry buildEntry(
CodebaseContext context,
AccessorIndex accessorIndex,
String ownerFqn,
String methodName,
List<String> implementations) {
LinkedHashSet<String> widenedConstants = new LinkedHashSet<>();
widenedConstants.addAll(ScanTimeAccessorConstants.resolve(context, accessorIndex, ownerFqn, methodName));
Optional<AccessorSummary> representative = findRepresentativeImplAccessor(accessorIndex, implementations, methodName);
for (String implementationFqn : implementations) {
widenedConstants.addAll(
ScanTimeAccessorConstants.resolve(context, accessorIndex, implementationFqn, methodName));
}
if (representative.isEmpty() && widenedConstants.isEmpty()) {
return null;
}
boolean staticallyComplete = isStaticallyComplete(accessorIndex, methodName, implementations);
return new PolymorphicAccessorEntry(
ownerFqn,
methodName,
representative,
List.copyOf(widenedConstants),
staticallyComplete);
}
private static boolean isStaticallyComplete(
AccessorIndex accessorIndex,
String methodName,
List<String> implementations) {
for (String implementationFqn : implementations) {
if (!isConstantLikeAccessor(accessorIndex, implementationFqn, methodName)) {
return false;
}
}
return true;
}
private static boolean isConstantLikeAccessor(
AccessorIndex accessorIndex,
String typeFqn,
String methodName) {
Optional<AccessorSummary> accessor = accessorIndex.lookup(typeFqn, methodName);
if (accessor.isEmpty() || !accessor.get().isGetter()) {
return false;
}
return accessor.get().kind() == AccessorKind.CONSTANT_GETTER;
}
private static Optional<AccessorSummary> findRepresentativeImplAccessor(
AccessorIndex accessorIndex,
List<String> implementations,
String methodName) {
for (String implementationFqn : implementations) {
Optional<AccessorSummary> implAccessor = accessorIndex.lookup(implementationFqn, methodName);
if (implAccessor.isPresent() && implAccessor.get().isGetter()) {
return implAccessor;
}
}
return Optional.empty();
}
private static Set<String> collectGetterMethodNames(
CodebaseContext context,
AccessorIndex accessorIndex,
String ownerFqn,
List<String> implementations) {
Set<String> methodNames = new LinkedHashSet<>();
for (AccessorSummary accessor : accessorIndex.accessorsForType(ownerFqn)) {
if (accessor.isGetter()) {
methodNames.add(accessor.methodName());
}
}
TypeDeclaration ownerType = context.getTypeDeclaration(ownerFqn);
if (ownerType != null) {
for (MethodDeclaration method : ownerType.getMethods()) {
String name = method.getName().getIdentifier();
if (looksLikeGetterName(name)) {
methodNames.add(name);
}
}
}
for (String implementationFqn : implementations) {
for (AccessorSummary accessor : accessorIndex.accessorsForType(implementationFqn)) {
if (accessor.isGetter()) {
methodNames.add(accessor.methodName());
}
}
}
return methodNames;
}
private static boolean isPolymorphicType(TypeDeclaration typeDeclaration) {
return typeDeclaration != null
&& (typeDeclaration.isInterface()
|| Modifier.isAbstract(typeDeclaration.getModifiers()));
}
private static boolean looksLikeGetterName(String methodName) {
return methodName.startsWith("get") || methodName.startsWith("is");
}
}

View File

@@ -0,0 +1,58 @@
package click.kamil.springstatemachineexporter.analysis.index;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Scan-time expansion of interface/abstract method calls to concrete implementation method FQNs.
*/
public final class PolymorphicMethodExpansionIndex {
private static final PolymorphicMethodExpansionIndex EMPTY =
new PolymorphicMethodExpansionIndex(Map.of(), 0);
private final Map<String, Map<String, List<String>>> byOwnerAndMethod;
private final int entryCount;
public PolymorphicMethodExpansionIndex(
Map<String, Map<String, List<String>>> byOwnerAndMethod,
int entryCount) {
this.byOwnerAndMethod = Collections.unmodifiableMap(deepCopy(byOwnerAndMethod));
this.entryCount = entryCount;
}
public static PolymorphicMethodExpansionIndex empty() {
return EMPTY;
}
public Optional<List<String>> lookup(String ownerFqn, String methodName) {
if (ownerFqn == null || methodName == null) {
return Optional.empty();
}
Map<String, List<String>> methods = byOwnerAndMethod.get(ownerFqn);
if (methods == null) {
return Optional.empty();
}
List<String> expanded = methods.get(methodName);
if (expanded == null || expanded.isEmpty()) {
return Optional.empty();
}
return Optional.of(expanded);
}
public int entryCount() {
return entryCount;
}
private static Map<String, Map<String, List<String>>> deepCopy(
Map<String, Map<String, List<String>>> source) {
Map<String, Map<String, List<String>>> copy = new HashMap<>();
for (Map.Entry<String, Map<String, List<String>>> entry : source.entrySet()) {
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
}
return copy;
}
}

View File

@@ -0,0 +1,81 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.Modifier;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Slf4j
public final class PolymorphicMethodExpansionIndexBuilder {
public PolymorphicMethodExpansionIndex build(CodebaseContext context) {
if (context == null) {
return PolymorphicMethodExpansionIndex.empty();
}
Map<String, Map<String, List<String>>> entries = new HashMap<>();
int entryCount = 0;
for (String typeFqn : context.getIndexedTypeFqns()) {
TypeDeclaration typeDeclaration = context.getTypeDeclaration(typeFqn);
if (!isPolymorphicType(typeDeclaration)) {
continue;
}
List<String> implementations = context.getImplementations(typeFqn);
if (implementations.isEmpty()) {
continue;
}
Set<String> methodNames = collectMethodNames(context, typeDeclaration, implementations);
for (String methodName : methodNames) {
List<String> expanded = new ArrayList<>();
for (String implementationFqn : implementations) {
expanded.add(implementationFqn + "." + methodName);
}
entries.computeIfAbsent(typeFqn, ignored -> new HashMap<>())
.put(methodName, List.copyOf(expanded));
entryCount++;
}
}
log.info("Built polymorphic method expansion index: {} entries across {} types",
entryCount, entries.size());
return new PolymorphicMethodExpansionIndex(entries, entryCount);
}
private static Set<String> collectMethodNames(
CodebaseContext context,
TypeDeclaration ownerType,
List<String> implementations) {
Set<String> methodNames = new LinkedHashSet<>();
if (ownerType != null) {
for (var method : ownerType.getMethods()) {
methodNames.add(method.getName().getIdentifier());
}
}
for (String implementationFqn : implementations) {
TypeDeclaration implementationType = context.getTypeDeclaration(implementationFqn);
if (implementationType == null) {
continue;
}
for (var method : implementationType.getMethods()) {
methodNames.add(method.getName().getIdentifier());
}
}
return methodNames;
}
private static boolean isPolymorphicType(TypeDeclaration typeDeclaration) {
return typeDeclaration != null
&& (typeDeclaration.isInterface()
|| Modifier.isAbstract(typeDeclaration.getModifiers()));
}
}

View File

@@ -0,0 +1,162 @@
package click.kamil.springstatemachineexporter.analysis.index;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
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.FieldDeclaration;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* Resolves accessor return constants using only scan-time information: indexed accessors,
* literal method bodies, and field initializers. No dataflow or constructor tracing.
*/
public final class ScanTimeAccessorConstants {
private ScanTimeAccessorConstants() {
}
public static List<String> resolve(
CodebaseContext context,
AccessorIndex accessorIndex,
String ownerFqn,
String methodName) {
if (context == null || accessorIndex == null || ownerFqn == null || methodName == null) {
return List.of();
}
Set<String> deduped = new LinkedHashSet<>();
Optional<AccessorSummary> indexed = accessorIndex.lookup(ownerFqn, methodName);
if (indexed.isPresent() && indexed.get().isGetter()) {
if (indexed.get().kind() == AccessorKind.CONSTANT_GETTER) {
deduped.addAll(extractReturnConstants(context, ownerFqn, methodName));
} else if (indexed.get().fieldName() != null) {
deduped.addAll(extractFieldInitializerConstants(
context, ownerFqn, indexed.get().fieldName()));
}
}
if (deduped.isEmpty()) {
deduped.addAll(extractReturnConstants(context, ownerFqn, methodName));
}
return List.copyOf(deduped);
}
private static List<String> extractReturnConstants(
CodebaseContext context,
String ownerFqn,
String methodName) {
TypeDeclaration typeDeclaration = context.getTypeDeclaration(ownerFqn);
if (typeDeclaration == null) {
return List.of();
}
MethodDeclaration methodDeclaration = context.findMethodDeclaration(typeDeclaration, methodName, true);
if (methodDeclaration == null || methodDeclaration.getBody() == null) {
return List.of();
}
List<String> constants = new ArrayList<>();
ConstantResolver constantResolver = context.getConstantResolver();
methodDeclaration.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
Expression expression = node.getExpression();
if (expression != null) {
addResolvedExpression(constantResolver, context, expression, constants);
}
return false;
}
});
return constants;
}
private static List<String> extractFieldInitializerConstants(
CodebaseContext context,
String ownerFqn,
String fieldName) {
TypeDeclaration typeDeclaration = context.getTypeDeclaration(ownerFqn);
if (typeDeclaration == null) {
return List.of();
}
List<String> constants = new ArrayList<>();
collectFieldInitializerConstants(context, typeDeclaration, fieldName, constants);
return constants;
}
private static void collectFieldInitializerConstants(
CodebaseContext context,
TypeDeclaration typeDeclaration,
String fieldName,
List<String> constants) {
ConstantResolver constantResolver = context.getConstantResolver();
for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) {
for (Object fragmentObj : fieldDeclaration.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment
&& fragment.getName().getIdentifier().equals(fieldName)
&& fragment.getInitializer() != null) {
addResolvedExpression(
constantResolver, context, fragment.getInitializer(), constants);
}
}
}
if (constants.isEmpty()) {
String superFqn = context.getSuperclassFqn(typeDeclaration);
if (superFqn != null && !"java.lang.Object".equals(superFqn)) {
TypeDeclaration superType = context.getTypeDeclaration(superFqn);
if (superType != null) {
collectFieldInitializerConstants(context, superType, fieldName, constants);
}
}
}
}
private static void addResolvedExpression(
ConstantResolver constantResolver,
CodebaseContext context,
Expression expression,
List<String> constants) {
if (constantResolver == null || expression == null) {
return;
}
String resolved = constantResolver.resolve(expression, context);
if (resolved == null) {
return;
}
if (resolved.startsWith("ENUM_SET:")) {
for (String value : resolved.substring(9).split(",")) {
addUnique(constants, parseEnumSetElement(value));
}
} else if (resolved.startsWith("MAP:")) {
List<String> mapValues = ConstantResolver.decodeMapLiteralValues(resolved);
if (mapValues != null) {
for (String mapValue : mapValues) {
addUnique(constants, mapValue);
}
}
} else if (resolved.startsWith("ARRAY:")) {
for (String arrayValue : resolved.substring(6).split("\\|", -1)) {
addUnique(constants, arrayValue);
}
} else {
addUnique(constants, resolved);
}
}
private static String parseEnumSetElement(String value) {
int lastDot = value.lastIndexOf('.');
int secondLastDot = lastDot > 0 ? value.lastIndexOf('.', lastDot - 1) : -1;
return secondLastDot >= 0 ? value.substring(secondLastDot + 1) : value;
}
private static void addUnique(List<String> constants, String value) {
if (value != null && !constants.contains(value)) {
constants.add(value);
}
}
}

View File

@@ -3,6 +3,7 @@ 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.index.GetterChainEdge;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
@@ -46,6 +47,7 @@ public final class AccessorResolver {
private final ConstantResolver constantResolver;
private final FieldInitializerFinder fieldInitializerFinder;
private final Map<String, List<String>> sessionCache = new HashMap<>();
private final Map<String, Optional<AccessorSummary>> indexedGetterCache = new HashMap<>();
public AccessorResolver(
CodebaseContext context,
@@ -63,6 +65,16 @@ public final class AccessorResolver {
if (className == null || className.isBlank()) {
return Optional.empty();
}
String cacheKey = className + "#" + methodName;
if (indexedGetterCache.containsKey(cacheKey)) {
return indexedGetterCache.get(cacheKey);
}
Optional<AccessorSummary> resolved = lookupIndexedGetterInternal(className, methodName);
indexedGetterCache.put(cacheKey, resolved);
return resolved;
}
private Optional<AccessorSummary> lookupIndexedGetterInternal(String className, String methodName) {
Optional<AccessorSummary> direct = context.getAccessorIndex().lookup(className, methodName);
if (direct.isPresent()) {
return direct;
@@ -78,9 +90,14 @@ public final class AccessorResolver {
}
}
List<String> implementations = context.getImplementations(className);
if (implementations == null) {
if (implementations == null || implementations.isEmpty()) {
return Optional.empty();
}
Optional<AccessorSummary> precomputed = context.getPolymorphicAccessorIndex()
.representativeImplAccessor(className, methodName);
if (precomputed.isPresent()) {
return precomputed;
}
for (String implementation : implementations) {
Optional<AccessorSummary> implAccessor = context.getAccessorIndex().lookup(implementation, methodName);
if (implAccessor.isPresent()) {
@@ -114,7 +131,7 @@ public final class AccessorResolver {
}
List<String> resolved = resolveInternal(ownerFqn, methodName, receiverContext, budget, visited);
if (!resolved.isEmpty()) {
if (!resolved.isEmpty() || isStaticCacheable(receiverContext)) {
List<String> cachedCopy = List.copyOf(resolved);
sessionCache.put(cacheKey, cachedCopy);
return cachedCopy;
@@ -127,6 +144,12 @@ public final class AccessorResolver {
return null;
}
Optional<GetterChainEdge> precomputed = context.getGetterChainIndex().lookup(ownerFqn, getterName);
if (precomputed.isPresent()) {
GetterChainEdge edge = precomputed.get();
return new GetterChainStep(edge.nextTypeFqn(), edge.fieldInitializerCic());
}
Optional<AccessorSummary> indexedAccessor = lookupIndexedGetter(ownerFqn, getterName);
if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) {
AccessorSummary accessor = indexedAccessor.get();
@@ -165,8 +188,20 @@ public final class AccessorResolver {
return null;
}
private static boolean isStaticCacheable(ReceiverContext receiverContext) {
if (receiverContext == null) {
return true;
}
if (receiverContext.cic() != null) {
return false;
}
Map<String, String> fieldValues = receiverContext.fieldValues();
return fieldValues == null || fieldValues.isEmpty();
}
public void clearSessionCache() {
sessionCache.clear();
indexedGetterCache.clear();
}
private List<String> resolveInternal(

View File

@@ -0,0 +1,40 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import java.util.AbstractMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Memoizes polymorphic class compatibility checks used during call-graph path matching.
*/
public final class ClassCompatibilityCache {
private final Map<Map.Entry<String, String>, Boolean> cache = new ConcurrentHashMap<>();
public boolean areClassesCompatible(String classNeighbor, String classTarget, ClassCompatibilityChecker checker) {
if (classNeighbor == null || classTarget == null) {
return false;
}
if (classNeighbor.equals(classTarget)) {
return true;
}
Map.Entry<String, String> key = cacheKey(classNeighbor, classTarget);
return cache.computeIfAbsent(key, ignored -> checker.check(classNeighbor, classTarget));
}
public void clear() {
cache.clear();
}
private static Map.Entry<String, String> cacheKey(String classNeighbor, String classTarget) {
if (classNeighbor.compareTo(classTarget) <= 0) {
return new AbstractMap.SimpleImmutableEntry<>(classNeighbor, classTarget);
}
return new AbstractMap.SimpleImmutableEntry<>(classTarget, classNeighbor);
}
@FunctionalInterface
public interface ClassCompatibilityChecker {
boolean check(String classNeighbor, String classTarget);
}
}

View File

@@ -32,6 +32,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
protected final FieldInitializerFinder fieldInitializerFinder;
protected final PathBindingEvaluator pathBindingEvaluator;
private final Map<String, ASTNode> parsedNodeCache = new HashMap<>();
private final Map<String, List<String>> polymorphicCallCache = new HashMap<>();
private ASTNode parseExpressionString(String expr) {
if (expr == null) return null;
@@ -97,6 +98,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
accessorResolver.clearSessionCache();
constructorAnalyzer.clearReturnedCicCache();
variableTracer.clearAnalysisCaches();
polymorphicCallCache.clear();
context.clearAnalysisCaches();
pathFinder.clearAnalysisCaches();
parsedNodeCache.clear();
Map<String, List<CallEdge>> callGraph = buildCallGraph();
List<CallChain> chains = new ArrayList<>();
@@ -628,7 +636,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
} else if (exprNode instanceof ClassInstanceCreation cic) {
declaredType = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType());
sourceMethod = "inline-instantiation";
polymorphicEvents.add(declaredType);
extractEventConstantsFromConstructor(cic, polymorphicEvents);
if (polymorphicEvents.isEmpty()) {
polymorphicEvents.add(declaredType);
}
} else if (exprNode instanceof ConditionalExpression cond) {
List<Expression> branches = new ArrayList<>();
branches.add(cond.getThenExpression());
@@ -703,12 +714,18 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
ce.getExpression() instanceof ClassInstanceCreation cic) {
declaredType = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType());
sourceMethod = "inline-instantiation";
polymorphicEvents.add(declaredType);
extractEventConstantsFromConstructor(cic, polymorphicEvents);
if (polymorphicEvents.isEmpty()) {
polymorphicEvents.add(declaredType);
}
} else if (exprNode instanceof CastExpression ce &&
ce.getExpression() instanceof ClassInstanceCreation cic) {
declaredType = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType());
sourceMethod = "inline-instantiation";
polymorphicEvents.add(declaredType);
extractEventConstantsFromConstructor(cic, polymorphicEvents);
if (polymorphicEvents.isEmpty()) {
polymorphicEvents.add(declaredType);
}
}
if (methodName != null) {
@@ -887,7 +904,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
cuToUse = (CompilationUnit) entryTd.getRoot();
}
}
List<String> constants = constantExtractor.resolveMethodReturnConstant(type, methodName, 0, visited, cuToUse);
List<String> constants = constantExtractor.resolveMethodReturnConstant(type, methodName, 0, visited, cuToUse, ResolutionBudget.defaults(), false);
for (String constant : constants) {
if (!polymorphicEvents.contains(constant)) {
polymorphicEvents.add(constant);
@@ -958,6 +975,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
return false;
});
qualifyBareEnumConstants(polymorphicEvents, expType);
}
List<String> unpacked = new ArrayList<>();
@@ -1127,9 +1145,365 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
protected String postProcessResolvedArgument(Expression originalExpr, String resolvedValue) {
// Preserve valueOf(variable) so trigger tracing can bind the variable per call-path
// instead of widening to all enum constants at graph-build time.
if (originalExpr instanceof MethodInvocation valueOfMi
&& "valueOf".equals(valueOfMi.getName().getIdentifier())
&& valueOfMi.arguments().size() == 1
&& valueOfMi.arguments().get(0) instanceof SimpleName) {
return originalExpr.toString();
}
// Preserve field-wired helper calls (routingHelper.payAction()) for per-endpoint literal forwarding.
if (originalExpr instanceof MethodInvocation helperMi
&& helperMi.getExpression() instanceof SimpleName helperReceiver
&& !helperReceiver.getIdentifier().isEmpty()
&& Character.isLowerCase(helperReceiver.getIdentifier().charAt(0))
&& helperMi.arguments().isEmpty()
&& resolvedValue != null
&& resolvedValue.matches("^[A-Z_][A-Z0-9_]*$")) {
return originalExpr.toString();
}
// Preserve getter calls on 'this' or 'super' for later resolution in trigger parameter tracing
// where we can substitute the actual receiver from the call edge.
boolean isThisOrSuperGetter = (originalExpr instanceof MethodInvocation mi
&& (mi.getExpression() instanceof ThisExpression || mi.getExpression() instanceof SuperMethodInvocation)
&& mi.arguments().isEmpty()) || (originalExpr instanceof SuperMethodInvocation smi && smi.arguments().isEmpty());
if (isThisOrSuperGetter) {
return originalExpr.toString();
}
// Narrow resolution for "localVar.getX()" patterns where localVar is initialized
// from a ClassInstanceCreation (directly or via a factory method).
if (originalExpr instanceof MethodInvocation getterMi
&& getterMi.getExpression() instanceof SimpleName instanceSn
&& getterMi.arguments().isEmpty()) {
String narrowed = tryNarrowGetterViaLocalVarChain(getterMi, instanceSn);
if (narrowed != null) {
return narrowed;
}
}
return resolvedValue;
}
protected String getReceiverString(Expression receiver) {
if (receiver == null) {
return null;
}
if (receiver instanceof ThisExpression) {
return "this";
}
if (receiver instanceof SuperMethodInvocation) {
return "super";
}
if (receiver instanceof SimpleName sn) {
return sn.getIdentifier();
}
return receiver.toString();
}
protected String findCallerReceiver(String callerFqn, String targetMethodFqn) {
int lastDot = callerFqn.lastIndexOf('.');
if (lastDot < 0) {
return null;
}
String callerClass = callerFqn.substring(0, lastDot);
String callerMethod = callerFqn.substring(lastDot + 1);
int targetLastDot = targetMethodFqn.lastIndexOf('.');
if (targetLastDot < 0) {
return null;
}
String targetMethodName = targetMethodFqn.substring(targetLastDot + 1);
TypeDeclaration callerTd = context.getTypeDeclaration(callerClass);
if (callerTd == null) {
return null;
}
MethodDeclaration callerMd = context.findMethodDeclaration(callerTd, callerMethod, false);
if (callerMd == null || callerMd.getBody() == null) {
return null;
}
final String[] foundReceiver = {null};
callerMd.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (foundReceiver[0] != null) {
return false;
}
String called = resolveCalledMethod(node);
if (called != null && (called.equals(targetMethodFqn) || called.endsWith("." + targetMethodName))) {
foundReceiver[0] = getReceiverString(node.getExpression());
return false;
}
return super.visit(node);
}
});
return foundReceiver[0];
}
protected String resolveReceiverTypeFallback(SimpleName receiverNameNode) {
String varName = receiverNameNode.getIdentifier();
CompilationUnit cu = receiverNameNode.getRoot() instanceof CompilationUnit rootCu ? rootCu : null;
TypeDeclaration staticTd = context.getTypeDeclaration(varName, cu);
if (staticTd != null) {
return context.getFqn(staticTd);
}
TypeDeclaration fallbackStaticTd = context.getTypeDeclaration(varName);
if (fallbackStaticTd != null) {
return context.getFqn(fallbackStaticTd);
}
MethodDeclaration enclosingMethod = findEnclosingMethod(receiverNameNode);
if (enclosingMethod != null) {
for (Object paramObj : enclosingMethod.parameters()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) paramObj;
if (svd.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(svd.getType(), receiverNameNode);
}
}
if (enclosingMethod.getBody() != null) {
Type[] foundType = new Type[1];
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
foundType[0] = node.getType();
}
}
return super.visit(node);
}
});
if (foundType[0] != null) {
return resolveTypeToFqn(foundType[0], receiverNameNode);
}
}
}
TypeDeclaration enclosingType = findEnclosingType(receiverNameNode);
TypeDeclaration currentType = enclosingType;
while (currentType != null) {
for (FieldDeclaration field : currentType.getFields()) {
for (Object fragObj : field.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(field.getType(), receiverNameNode);
}
}
}
String superclass = context.getSuperclassFqn(currentType);
currentType = superclass != null ? context.getTypeDeclaration(superclass) : null;
}
return null;
}
protected String resolveTypeToFqn(Type type, ASTNode contextNode) {
if (type == null) {
return null;
}
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), contextNode);
} else {
simpleName = type.toString();
}
CompilationUnit cu = (CompilationUnit) contextNode.getRoot();
TypeDeclaration td = context.getTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
return simpleName;
}
private String tryNarrowGetterViaLocalVarChain(MethodInvocation getterCall, SimpleName instanceSn) {
MethodDeclaration enclosing = findEnclosingMethod(getterCall);
if (enclosing == null || enclosing.getBody() == null) {
return null;
}
String varName = instanceSn.getIdentifier();
String getterName = getterCall.getName().getIdentifier();
String[] varTypeName = {null};
Expression[] initExpr = {null};
enclosing.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement vds) {
for (Object fragObj : vds.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
varTypeName[0] = vds.getType().toString();
initExpr[0] = frag.getInitializer();
}
}
return super.visit(vds);
}
});
if (varTypeName[0] == null) {
return null;
}
ClassInstanceCreation cic = null;
String receiverType = null;
if (initExpr[0] instanceof ClassInstanceCreation directCic) {
cic = directCic;
} else if (initExpr[0] instanceof MethodInvocation factoryMi
&& (factoryMi.getExpression() instanceof SimpleName || factoryMi.getExpression() instanceof QualifiedName)) {
if (factoryMi.getExpression() instanceof SimpleName receiverSn) {
receiverType = resolveReceiverTypeFallback(receiverSn);
} else if (factoryMi.getExpression() instanceof QualifiedName qn) {
receiverType = qn.getFullyQualifiedName();
}
if (receiverType != null) {
cic = findReturnedCIC(receiverType, factoryMi.getName().getIdentifier(), context);
}
}
if (cic == null) {
if (initExpr[0] instanceof MethodInvocation initMi) {
String propName = getterName.startsWith("get") ? getterName.substring(3) : getterName;
Expression currentMi = initMi;
while (currentMi instanceof MethodInvocation chainMi) {
String mName = chainMi.getName().getIdentifier();
if (mName.equalsIgnoreCase("set" + propName) || mName.equalsIgnoreCase(propName)) {
if (!chainMi.arguments().isEmpty()) {
return chainMi.arguments().get(0).toString();
}
}
currentMi = chainMi.getExpression();
}
}
return null;
}
CompilationUnit cu = (CompilationUnit) getterCall.getRoot();
TypeDeclaration varTypeTd = context.getTypeDeclaration(varTypeName[0], cu);
if (varTypeTd == null) {
varTypeTd = context.getTypeDeclaration(varTypeName[0]);
}
if (varTypeTd == null) {
return null;
}
Map<String, String> initialLocals = new HashMap<>();
if (initExpr[0] instanceof MethodInvocation factoryMi) {
MethodDeclaration md = context.findMethodDeclaration(
context.getTypeDeclaration(receiverType != null ? receiverType : varTypeName[0]),
factoryMi.getName().getIdentifier(),
true);
if (md != null) {
for (int i = 0; i < md.parameters().size() && i < factoryMi.arguments().size(); i++) {
SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(i);
Expression arg = (Expression) factoryMi.arguments().get(i);
String resolvedVal = constantResolver.resolve(arg, context);
if (resolvedVal != null) {
initialLocals.put(param.getName().getIdentifier(), resolvedVal);
}
}
}
}
Map<String, String> fieldValues = constructorAnalyzer.buildFieldValuesFromCIC(
varTypeTd, cic, context, this::evaluateMethodCallInConstructor, initialLocals);
if (fieldValues.isEmpty()) {
return null;
}
trackSetterCallsBetween(enclosing.getBody(), varName, getterCall, fieldValues, context);
MethodDeclaration getter = context.findMethodDeclaration(varTypeTd, getterName, true);
if (getter == null || getter.getBody() == null) {
return null;
}
return constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, new HashSet<>());
}
private void trackSetterCallsBetween(
Block methodBody,
String varName,
MethodInvocation getterCall,
Map<String, String> fieldValues,
CodebaseContext context) {
List<ASTNode> statements = new ArrayList<>();
for (Object stmtObj : methodBody.statements()) {
statements.add((ASTNode) stmtObj);
}
int varDeclIndex = -1;
int getterCallIndex = -1;
for (int i = 0; i < statements.size(); i++) {
ASTNode stmt = statements.get(i);
if (varDeclIndex == -1 && stmt instanceof VariableDeclarationStatement vds) {
for (Object fragObj : vds.fragments()) {
if (fragObj instanceof VariableDeclarationFragment frag
&& frag.getName().getIdentifier().equals(varName)) {
varDeclIndex = i;
break;
}
}
}
if (getterCallIndex == -1 && stmt.toString().contains(getterCall.toString())) {
getterCallIndex = i;
break;
}
}
if (varDeclIndex < 0 || getterCallIndex <= varDeclIndex) {
return;
}
for (int i = varDeclIndex + 1; i < getterCallIndex; i++) {
ASTNode stmt = statements.get(i);
if (stmt instanceof ExpressionStatement es) {
Expression expr = es.getExpression();
if (expr instanceof MethodInvocation mi) {
if (mi.getExpression() instanceof SimpleName sn && sn.getIdentifier().equals(varName)) {
String methodName = mi.getName().getIdentifier();
if (methodName.startsWith("set") && methodName.length() > 3 && !mi.arguments().isEmpty()) {
String fieldName = Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4);
String val = constantResolver.resolve((Expression) mi.arguments().get(0), context);
if (val != null) {
fieldValues.put(fieldName, val);
fieldValues.put("this." + fieldName, val);
}
}
}
} else if (expr instanceof Assignment assignment) {
if (assignment.getLeftHandSide() instanceof FieldAccess fa) {
if (fa.getExpression() instanceof SimpleName sn && sn.getIdentifier().equals(varName)) {
String val = constantResolver.resolve(assignment.getRightHandSide(), context);
if (val != null) {
fieldValues.put(fa.getName().getIdentifier(), val);
fieldValues.put("this." + fa.getName().getIdentifier(), val);
}
}
}
}
}
}
}
protected String resolveMethodInType(TypeDeclaration td, String methodName) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null) {
@@ -1874,49 +2248,60 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
String baseCalled = resolveCalledMethod(node);
if (baseCalled == null) return Collections.emptyList();
List<String> allResolved = new ArrayList<>();
if (node.getExpression() == null) {
return Collections.singletonList(baseCalled);
}
if (baseCalled.contains(".")) {
String className = baseCalled.substring(0, baseCalled.lastIndexOf('.'));
String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
List<String> impls = context.getImplementations(className);
boolean shouldExpand = true;
if (impls.isEmpty()) {
shouldExpand = false;
} else if (td == null) {
// Do not polymorphically expand external generic types (e.g. java.lang.Runnable, Spring Action)
// across the entire codebase. This prevents massive false-positive tangling.
shouldExpand = false;
} else {
boolean isImplicitThis = node.getExpression() instanceof ThisExpression;
if (!td.isInterface() && !Modifier.isAbstract(td.getModifiers()) && isImplicitThis) {
shouldExpand = false;
}
}
if (!baseCalled.contains(".")) {
return Collections.singletonList(baseCalled);
}
if (shouldExpand) {
boolean isAbstractOrInterface = td.isInterface() || Modifier.isAbstract(td.getModifiers());
String className = baseCalled.substring(0, baseCalled.lastIndexOf('.'));
String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
boolean isImplicitThis = node.getExpression() instanceof ThisExpression;
String cacheKey = baseCalled + "#" + isImplicitThis;
List<String> cached = polymorphicCallCache.get(cacheKey);
if (cached != null) {
return cached;
}
List<String> allResolved = new ArrayList<>();
List<String> impls = context.getImplementations(className);
boolean shouldExpand = true;
if (impls.isEmpty()) {
shouldExpand = false;
} else if (td == null) {
shouldExpand = false;
} else if (!td.isInterface() && !Modifier.isAbstract(td.getModifiers()) && isImplicitThis) {
shouldExpand = false;
}
if (shouldExpand) {
boolean isAbstractOrInterface = td.isInterface() || Modifier.isAbstract(td.getModifiers());
Optional<List<String>> precomputed =
context.getPolymorphicMethodExpansionIndex().lookup(className, methodName);
if (precomputed.isPresent()) {
if (!isAbstractOrInterface) {
allResolved.add(baseCalled);
}
allResolved.addAll(precomputed.get());
} else {
if (!isAbstractOrInterface) {
allResolved.add(baseCalled);
}
for (String impl : impls) {
allResolved.add(impl + "." + methodName);
}
} else {
allResolved.add(baseCalled);
}
} else {
allResolved.add(baseCalled);
}
return allResolved;
List<String> frozen = List.copyOf(allResolved);
polymorphicCallCache.put(cacheKey, frozen);
return frozen;
}
/**
@@ -1958,6 +2343,44 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return current;
}
private void qualifyBareEnumConstants(List<String> polymorphicEvents, String expectedType) {
if (expectedType == null || polymorphicEvents == null || polymorphicEvents.isEmpty()) {
return;
}
List<String> enumValues = context.getEnumValues(expectedType);
String simpleExpectedType = expectedType;
if (enumValues == null) {
simpleExpectedType = expectedType.contains(".")
? expectedType.substring(expectedType.lastIndexOf('.') + 1)
: expectedType;
enumValues = context.getEnumValues(simpleExpectedType);
} else if (expectedType.contains(".")) {
simpleExpectedType = expectedType.substring(expectedType.lastIndexOf('.') + 1);
}
if (enumValues == null || enumValues.isEmpty()) {
return;
}
for (int i = 0; i < polymorphicEvents.size(); i++) {
String pe = polymorphicEvents.get(i);
if (pe == null || pe.contains(".") || pe.startsWith("<SYMBOLIC:") || pe.startsWith("ENUM_SET:")) {
continue;
}
if (!pe.matches("^[A-Z_][A-Z0-9_]*$")) {
continue;
}
boolean matchesEnum = false;
for (String enumValue : enumValues) {
if (enumValue.endsWith("." + pe)) {
matchesEnum = true;
break;
}
}
if (matchesEnum) {
polymorphicEvents.set(i, simpleExpectedType + "." + pe);
}
}
}
private String resolveValueOfEnumTypeName(MethodInvocation mi) {
if (mi.getExpression() instanceof SimpleName sn) {
return sn.getIdentifier();
@@ -1981,7 +2404,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
Map<String, String> parameterValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, i);
String traced = variableTracer.traceLocalVariable(target, argName, parameterValues);
if (traced != null && !traced.equals(argName)) {
String normalized = normalizeEnumConstantName(traced);
String normalized = resolveBoundArgumentToEnumConstant(traced, caller);
if (normalized != null) {
return normalized;
}
@@ -1994,7 +2417,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
for (CallEdge edge : edges) {
if (edge.getTargetMethod().equals(target) || pathFinder.isHeuristicMatch(edge.getTargetMethod(), target)) {
if (paramIndex < edge.getArguments().size()) {
String normalized = normalizeEnumConstantName(edge.getArguments().get(paramIndex));
String normalized = resolveBoundArgumentToEnumConstant(
edge.getArguments().get(paramIndex), caller);
if (normalized != null) {
return normalized;
}
@@ -2007,6 +2431,62 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return null;
}
private String resolveBoundArgumentToEnumConstant(String boundValue, String scopeMethodFqn) {
if (boundValue == null || boundValue.isBlank()) {
return null;
}
String fromLiteral = normalizeEnumConstantName(boundValue);
if (fromLiteral != null) {
return fromLiteral;
}
return resolveMethodCallExpressionToEnumConstant(boundValue, scopeMethodFqn);
}
private String resolveMethodCallExpressionToEnumConstant(String expr, String scopeMethodFqn) {
if (expr == null || !expr.contains("(")) {
return null;
}
ASTNode node = parseExpressionString(expr);
while (node instanceof ParenthesizedExpression pe) {
node = pe.getExpression();
}
if (!(node instanceof MethodInvocation mi) || !mi.arguments().isEmpty()) {
return null;
}
String methodName = mi.getName().getIdentifier();
String receiverType = null;
Expression receiver = mi.getExpression();
if (receiver instanceof SimpleName sn) {
receiverType = variableTracer.getVariableDeclaredType(scopeMethodFqn, sn.getIdentifier());
} else if (receiver instanceof FieldAccess fa) {
receiverType = variableTracer.getVariableDeclaredType(scopeMethodFqn, fa.getName().getIdentifier());
}
if (receiverType == null) {
return null;
}
String simpleReceiverType = receiverType.contains("<")
? receiverType.substring(0, receiverType.indexOf('<'))
: receiverType;
TypeDeclaration td = context.getTypeDeclaration(simpleReceiverType);
if (td == null) {
td = context.getTypeDeclaration(receiverType);
}
if (td == null) {
return null;
}
String ownerFqn = context.getFqn(td);
CompilationUnit cu = td.getRoot() instanceof CompilationUnit contextCu ? contextCu : null;
List<String> constants = constantExtractor.resolveMethodReturnConstant(
ownerFqn, methodName, 0, new HashSet<>(), cu);
if (constants.isEmpty()) {
return null;
}
if (constants.size() == 1) {
return normalizeEnumConstantName(constants.get(0));
}
return null;
}
private String normalizeEnumConstantName(String value) {
if (value == null || value.isBlank()) {
return null;
@@ -2141,6 +2621,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return false;
}
private void extractEventConstantsFromConstructor(ClassInstanceCreation cic, List<String> polymorphicEvents) {
if (cic.getAnonymousClassDeclaration() != null || cic.arguments().isEmpty()) {
return;
}
constantExtractor.extractConstantsFromExpression((Expression) cic.arguments().get(0), polymorphicEvents);
}
private void addConstantsFromTracedExpression(
Expression expr, List<String> constants, List<String> path, Map<String, List<CallEdge>> callGraph, String scopeMethod) {
List<Expression> traced = variableTracer.traceVariableAll(expr);

View File

@@ -147,19 +147,18 @@ public class CallGraphBuilder {
}
List<String> polymorphicEvents = new ArrayList<>();
if (resolvedValue.matches(".*\\.[a-zA-Z0-9_]+\\(\\)")) {
String varName = resolvedValue.substring(0, resolvedValue.indexOf('.'));
String methodName = resolvedValue.substring(resolvedValue.indexOf('.') + 1, resolvedValue.indexOf('('));
if (!methodSuffix.isEmpty() && methodSuffix.matches("\\.[a-zA-Z0-9_]+\\(\\)")
&& currentParamName.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) {
String varName = currentParamName;
String methodName = methodSuffix.substring(1, methodSuffix.length() - 2);
// Resolve in the first method in the path where the variable might be declared
for (String methodFqn : path) {
String declaredType = getVariableDeclaredType(methodFqn, varName);
if (declaredType != null) {
System.out.println("DEEP TRACE: " + methodFqn + " " + varName + " -> " + declaredType);
List<String> typesToInspect = new ArrayList<>();
typesToInspect.add(declaredType);
typesToInspect.addAll(context.getImplementations(declaredType));
System.out.println("DEEP TRACE IMPLS: " + typesToInspect);
for (String type : typesToInspect) {
Set<String> visited = new HashSet<>();
@@ -177,7 +176,6 @@ public class CallGraphBuilder {
}
}
List<String> constants = resolveMethodReturnConstant(type, methodName, 0, visited, cuToUse);
System.out.println("DEEP TRACE RETURN: " + type + " -> " + constants);
for (String constant : constants) {
if (!polymorphicEvents.contains(constant)) {
polymorphicEvents.add(constant);
@@ -265,7 +263,6 @@ public class CallGraphBuilder {
List<String> constants = new ArrayList<>();
TypeDeclaration td = contextCu != null ? context.getTypeDeclaration(className, contextCu) : context.getTypeDeclaration(className);
if (td == null) {
System.out.println("DEEP TRACE FAILED TO FIND TD: " + className);
}
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
@@ -279,7 +276,6 @@ public class CallGraphBuilder {
if (retExpr instanceof MethodInvocation mi) {
// Follow delegation first
String called = resolveCalledMethod(mi);
System.out.println("DEEP TRACE RESOLVED CALLED: " + called);
if (called != null && called.contains(".")) {
if (visited.contains(called)) {
handled = true;
@@ -549,9 +545,16 @@ public class CallGraphBuilder {
}
}
String val = constantResolver.resolve(expr, context);
if (val == null) {
val = expr.toString(); // Fallback to raw string (handles Enums, 'new Class()', etc)
String val;
if (expr instanceof MethodInvocation mi && mi.getExpression() instanceof SimpleName
&& mi.arguments().isEmpty()) {
// Keep local getter chains (e.g. event.getType()) for downstream parameter tracing.
val = expr.toString();
} else {
val = constantResolver.resolve(expr, context);
if (val == null) {
val = expr.toString(); // Fallback to raw string (handles Enums, 'new Class()', etc)
}
}
args.add(val);
}

View File

@@ -18,6 +18,12 @@ public class CallGraphPathFinder {
this.context = context;
}
public void clearAnalysisCaches() {
heuristicCache.clear();
memoCache.clear();
unreachableCache.clear();
}
public List<String> findPath(String start, String target, Map<String, List<CallEdge>> graph, Set<String> visited) {
if (start.equals(target)) return new ArrayList<>(List.of(start));
if (!visited.add(start)) {
@@ -360,19 +366,10 @@ public class CallGraphPathFinder {
if (simpleClassNeighbor.equals(simpleClassTarget)) return true;
List<String> impls = context.getImplementations(classTarget);
if (impls != null) {
for (String impl : impls) {
if (impl.equals(classNeighbor) || impl.endsWith("." + classNeighbor)) return true;
}
if (context.areClassesPolymorphicallyCompatible(classNeighbor, classTarget)) {
return true;
}
List<String> implsN = context.getImplementations(classNeighbor);
if (implsN != null) {
for (String impl : implsN) {
if (impl.equals(classTarget) || impl.endsWith("." + classTarget)) return true;
}
}
return false;
}

View File

@@ -294,6 +294,17 @@ public class ConstantExtractor {
Set<String> visited,
CompilationUnit contextCu,
ResolutionBudget budget) {
return resolveMethodReturnConstant(className, methodName, depth, visited, contextCu, budget, true);
}
public List<String> resolveMethodReturnConstant(
String className,
String methodName,
int depth,
Set<String> visited,
CompilationUnit contextCu,
ResolutionBudget budget,
boolean allowWidenToImplementations) {
log.debug("ConstantExtractor.resolveMethodReturnConstant CALLED: className={}, methodName={}", className, methodName);
log.debug("Entering resolveMethodReturnConstant with className={}, methodName={}, depth={}", className, methodName, depth);
if (budget == null) {
@@ -316,9 +327,19 @@ public class ConstantExtractor {
tempTd = context.getTypeDeclaration(className);
}
final TypeDeclaration td = tempTd;
boolean widenToImplementations = td == null
boolean widenToImplementations = allowWidenToImplementations
&& (td == null
|| td.isInterface()
|| Modifier.isAbstract(td.getModifiers());
|| Modifier.isAbstract(td.getModifiers()));
if (widenToImplementations && depth == 0) {
Optional<List<String>> precomputed = context.getPolymorphicAccessorIndex()
.widenedReturnConstants(className, methodName);
if (precomputed.isPresent()) {
visited.remove(fqn);
return new ArrayList<>(precomputed.get());
}
}
List<String> constants = new ArrayList<>();
if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) {
@@ -371,7 +392,7 @@ public class ConstantExtractor {
if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) {
List<String> delegationResult = resolveMethodReturnConstant(
cName, mName, depth + 1, visited, contextCu, activeBudget);
cName, mName, depth + 1, visited, contextCu, activeBudget, allowWidenToImplementations);
constants.addAll(delegationResult);
handled = true;
}
@@ -391,7 +412,7 @@ public class ConstantExtractor {
if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) {
List<String> delegationResult = resolveMethodReturnConstant(
cName, mName, depth + 1, visited, contextCu, activeBudget);
cName, mName, depth + 1, visited, contextCu, activeBudget, allowWidenToImplementations);
constants.addAll(delegationResult);
handled = true;
}
@@ -438,7 +459,7 @@ public class ConstantExtractor {
if (impls != null) {
for (String implName : impls) {
List<String> delegationResult = resolveMethodReturnConstant(
implName, methodName, depth + 1, visited, contextCu, activeBudget);
implName, methodName, depth + 1, visited, contextCu, activeBudget, allowWidenToImplementations);
for (String value : delegationResult) {
if (!constants.contains(value)) {
constants.add(value);
@@ -472,6 +493,13 @@ public class ConstantExtractor {
String requestingTypeFqn,
CompilationUnit contextCu,
Set<String> visited) {
if (accessor != null && isSafeForPrecomputedFieldConstants(accessor)) {
Optional<List<String>> precomputed = context.getIndexedFieldConstantsIndex()
.lookup(accessor.ownerFqn(), accessor.methodName());
if (precomputed.isPresent()) {
return new ArrayList<>(precomputed.get());
}
}
return AccessorFieldResolver.resolveFieldConstants(
accessor,
requestingTypeFqn,
@@ -508,9 +536,14 @@ public class ConstantExtractor {
}
}
List<String> implementations = context.getImplementations(className);
if (implementations == null) {
if (implementations == null || implementations.isEmpty()) {
return Optional.empty();
}
Optional<AccessorSummary> precomputed = context.getPolymorphicAccessorIndex()
.representativeImplAccessor(className, methodName);
if (precomputed.isPresent()) {
return precomputed;
}
for (String implementation : implementations) {
Optional<AccessorSummary> implAccessor = context.getAccessorIndex().lookup(implementation, methodName);
if (implAccessor.isPresent()) {
@@ -551,6 +584,20 @@ public class ConstantExtractor {
return constants;
}
private boolean isSafeForPrecomputedFieldConstants(AccessorSummary accessor) {
if (accessor.fieldName() == null || accessor.declaringFqn() == null) {
return accessor.kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.CONSTANT_GETTER;
}
if (!accessor.ownerFqn().equals(accessor.declaringFqn())) {
return false;
}
TypeDeclaration ownerType = context.getTypeDeclaration(accessor.ownerFqn());
return ownerType != null
&& !ownerType.isInterface()
&& !Modifier.isAbstract(ownerType.getModifiers());
}
public String parseEnumSetElement(String eVal) {
return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal;
}

View File

@@ -14,6 +14,8 @@ public class ConstructorAnalyzer {
private final ConstantResolver constantResolver;
private VariableTracer variableTracer;
private ConstantExtractor constantExtractor;
private final Map<String, ClassInstanceCreation> returnedCicCache = new HashMap<>();
private final Set<String> returnedCicAbsent = new HashSet<>();
@FunctionalInterface
public interface RhsResolver {
@@ -33,6 +35,11 @@ public class ConstructorAnalyzer {
this.constantExtractor = constantExtractor;
}
public void clearReturnedCicCache() {
returnedCicCache.clear();
returnedCicAbsent.clear();
}
public List<String> traceFieldInConstructors(TypeDeclaration td, String fieldName, CodebaseContext context, Set<String> visited) {
return traceFieldInConstructors(td, fieldName, context, visited, ResolutionBudget.defaults());
}
@@ -480,7 +487,20 @@ public class ConstructorAnalyzer {
public ClassInstanceCreation findReturnedCIC(
String className, String methodName, CodebaseContext context) {
return findReturnedCIC(className, methodName, context, new HashSet<>(), 0);
String cacheKey = className + "#" + methodName;
if (returnedCicAbsent.contains(cacheKey)) {
return null;
}
if (returnedCicCache.containsKey(cacheKey)) {
return returnedCicCache.get(cacheKey);
}
ClassInstanceCreation resolved = findReturnedCIC(className, methodName, context, new HashSet<>(), 0);
if (resolved != null) {
returnedCicCache.put(cacheKey, resolved);
} else {
returnedCicAbsent.add(cacheKey);
}
return resolved;
}
private ClassInstanceCreation findReturnedCIC(

View File

@@ -141,32 +141,6 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
return graph;
}
@Override
protected String postProcessResolvedArgument(org.eclipse.jdt.core.dom.Expression originalExpr, String resolvedValue) {
// Preserve getter calls on 'this' or 'super' for later resolution in trigger parameter tracing
// where we can substitute the actual receiver from the call edge
boolean isThisOrSuperGetter = (originalExpr instanceof org.eclipse.jdt.core.dom.MethodInvocation mi
&& (mi.getExpression() instanceof org.eclipse.jdt.core.dom.ThisExpression || mi.getExpression() instanceof org.eclipse.jdt.core.dom.SuperMethodInvocation)
&& mi.arguments().isEmpty()) || (originalExpr instanceof org.eclipse.jdt.core.dom.SuperMethodInvocation smi && smi.arguments().isEmpty());
if (isThisOrSuperGetter) {
return originalExpr.toString(); // Preserve "this.getTransitionType()" or "super.getEvent()" for later substitution
}
// Narrow resolution for "localVar.getX()" patterns where localVar is initialized
// from a ClassInstanceCreation (directly or via a factory method). This avoids
// the broad ENUM_SET fallback when the getter transforms one enum to another.
if (originalExpr instanceof org.eclipse.jdt.core.dom.MethodInvocation getterMi
&& getterMi.getExpression() instanceof org.eclipse.jdt.core.dom.SimpleName instanceSn
&& getterMi.arguments().isEmpty()) {
String narrowed = tryNarrowGetterViaLocalVarChain(getterMi, instanceSn);
if (narrowed != null) {
return narrowed;
}
}
return resolvedValue;
}
private String findDefiningClass(String className, String methodName) {
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) return null;
@@ -230,351 +204,6 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
return null;
}
protected String resolveReceiverTypeFallback(SimpleName receiverNameNode) {
String varName = receiverNameNode.getIdentifier();
CompilationUnit cu = receiverNameNode.getRoot() instanceof CompilationUnit ? (CompilationUnit) receiverNameNode.getRoot() : null;
TypeDeclaration staticTd = context.getTypeDeclaration(varName, cu);
if (staticTd != null) {
return context.getFqn(staticTd);
}
TypeDeclaration fallbackStaticTd = context.getTypeDeclaration(varName);
if (fallbackStaticTd != null) {
return context.getFqn(fallbackStaticTd);
}
// 1. Check local variables in enclosing method
MethodDeclaration enclosingMethod = findEnclosingMethod(receiverNameNode);
if (enclosingMethod != null) {
// Check parameters
for (Object paramObj : enclosingMethod.parameters()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) paramObj;
if (svd.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(svd.getType(), receiverNameNode);
}
}
// Check method body (local variables)
if (enclosingMethod.getBody() != null) {
Type[] foundType = new Type[1];
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
foundType[0] = node.getType();
}
}
return super.visit(node);
}
});
if (foundType[0] != null) {
return resolveTypeToFqn(foundType[0], receiverNameNode);
}
}
}
// 2. Check fields in enclosing class and its superclasses
TypeDeclaration enclosingType = findEnclosingType(receiverNameNode);
TypeDeclaration currentType = enclosingType;
while (currentType != null) {
for (FieldDeclaration field : currentType.getFields()) {
for (Object fragObj : field.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(field.getType(), receiverNameNode);
}
}
}
String superclass = context.getSuperclassFqn(currentType);
currentType = superclass != null ? context.getTypeDeclaration(superclass) : null;
}
return null;
}
protected String resolveTypeToFqn(Type type, ASTNode contextNode) {
if (type == null) return null;
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), contextNode);
} else {
simpleName = type.toString();
}
CompilationUnit cu = (CompilationUnit) contextNode.getRoot();
TypeDeclaration td = context.getTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
// Fallback to import matching if CodebaseContext doesn't know it (e.g., external library)
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
return simpleName;
}
/**
* Attempts to narrow the resolution of {@code instanceVar.getterMethod()} when the
* instance variable is initialised from a {@link org.eclipse.jdt.core.dom.ClassInstanceCreation}
* (either directly or via a factory/mapper method).
*
* <p>This handles both:
* <ul>
* <li>Simple pass-through: {@code Wrapper(MyEnum e){this.e=e;} getE(){return e;}}</li>
* <li>Transformation: {@code getSmEvent(){return switch(dtoEnum){case A->SmEvents.X;…};}}</li>
* </ul>
*
* @return the resolved constant string, or {@code null} when narrowing is not possible
*/
private String tryNarrowGetterViaLocalVarChain(
MethodInvocation getterCall, SimpleName instanceSn) {
MethodDeclaration enclosing = findEnclosingMethod(getterCall);
if (enclosing == null || enclosing.getBody() == null) return null;
String varName = instanceSn.getIdentifier();
String getterName = getterCall.getName().getIdentifier();
// Find the declared type and initialiser of the local variable
String[] varTypeName = {null};
Expression[] initExpr = {null};
enclosing.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement vds) {
for (Object fragObj : vds.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
varTypeName[0] = vds.getType().toString();
initExpr[0] = frag.getInitializer();
}
}
return super.visit(vds);
}
});
if (varTypeName[0] == null) return null;
// Obtain the ClassInstanceCreation that created the object
org.eclipse.jdt.core.dom.ClassInstanceCreation cic = null;
String receiverType = null;
if (initExpr[0] instanceof org.eclipse.jdt.core.dom.ClassInstanceCreation directCic) {
cic = directCic;
} else if (initExpr[0] instanceof MethodInvocation factoryMi
&& (factoryMi.getExpression() instanceof SimpleName || factoryMi.getExpression() instanceof QualifiedName)) {
// e.g. "mapper.toMsg(dto)" or "com.example.Factory.toMsg()"
if (factoryMi.getExpression() instanceof SimpleName receiverSn) {
receiverType = resolveReceiverTypeFallback(receiverSn);
} else if (factoryMi.getExpression() instanceof QualifiedName qn) {
receiverType = qn.getFullyQualifiedName();
}
if (receiverType != null) {
cic = findReturnedCIC(receiverType, factoryMi.getName().getIdentifier(), context);
}
}
if (cic == null) {
// Support builder pattern: e.g. EventWrapper.builder().event(TransitionEnum.STATE_X).build()
if (initExpr[0] instanceof MethodInvocation initMi) {
String propName = getterName.startsWith("get") ? getterName.substring(3) : getterName;
Expression currentMi = initMi;
while (currentMi instanceof MethodInvocation chainMi) {
String mName = chainMi.getName().getIdentifier();
if (mName.equalsIgnoreCase("set" + propName) || mName.equalsIgnoreCase(propName)) {
if (!chainMi.arguments().isEmpty()) {
return chainMi.arguments().get(0).toString();
}
}
currentMi = chainMi.getExpression();
}
}
return null;
}
// Resolve the variable's declared type to a TypeDeclaration
CompilationUnit cu = (CompilationUnit) getterCall.getRoot();
TypeDeclaration varTypeTd = context.getTypeDeclaration(varTypeName[0], cu);
if (varTypeTd == null) varTypeTd = context.getTypeDeclaration(varTypeName[0]);
if (varTypeTd == null) return null;
// Evaluate the getter body with field values substituted from the CIC's constructor args
java.util.Map<String, String> initialLocals = new java.util.HashMap<>();
if (initExpr[0] instanceof MethodInvocation factoryMi) {
MethodDeclaration md = context.findMethodDeclaration(context.getTypeDeclaration(receiverType != null ? receiverType : varTypeName[0]), factoryMi.getName().getIdentifier(), true);
if (md != null) {
for (int i = 0; i < md.parameters().size() && i < factoryMi.arguments().size(); i++) {
SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(i);
Expression arg = (Expression) factoryMi.arguments().get(i);
String resolvedVal = constantResolver.resolve(arg, context);
if (resolvedVal != null) {
initialLocals.put(param.getName().getIdentifier(), resolvedVal);
}
}
}
}
java.util.Map<String, String> fieldValues = constructorAnalyzer.buildFieldValuesFromCIC(varTypeTd, cic, context, this::evaluateMethodCallInConstructor, initialLocals);
if (fieldValues.isEmpty()) return null;
// Track setter calls on the variable between its initialization and the getter call
// to capture field updates like e.setType("NEW_VALUE")
trackSetterCallsBetween(enclosing.getBody(), varName, getterCall, fieldValues, context);
MethodDeclaration getter = context.findMethodDeclaration(varTypeTd, getterName, true);
if (getter == null || getter.getBody() == null) return null;
return constantResolver.evaluateMethodBodyWithLocals(
getter, fieldValues, context, new java.util.HashSet<>());
}
/**
* Scans the method body for setter calls or field assignments to the given variable
* that occur between the variable's initialization and the getter call.
* Updates fieldValues with any field values set via setters.
*/
private void trackSetterCallsBetween(org.eclipse.jdt.core.dom.Block methodBody, String varName,
org.eclipse.jdt.core.dom.MethodInvocation getterCall,
java.util.Map<String, String> fieldValues,
CodebaseContext context) {
java.util.List<org.eclipse.jdt.core.dom.ASTNode> statements = new java.util.ArrayList<>();
for (Object stmtObj : methodBody.statements()) {
statements.add((org.eclipse.jdt.core.dom.ASTNode) stmtObj);
}
int varDeclIndex = -1;
int getterCallIndex = -1;
for (int i = 0; i < statements.size(); i++) {
org.eclipse.jdt.core.dom.ASTNode stmt = statements.get(i);
// Find variable declaration
if (varDeclIndex == -1) {
if (stmt instanceof org.eclipse.jdt.core.dom.VariableDeclarationStatement vds) {
for (Object fragObj : vds.fragments()) {
if (fragObj instanceof org.eclipse.jdt.core.dom.VariableDeclarationFragment frag
&& frag.getName().getIdentifier().equals(varName)) {
varDeclIndex = i;
break;
}
}
}
}
// Find getter call
if (getterCallIndex == -1) {
if (stmt.toString().contains(getterCall.toString())) {
getterCallIndex = i;
break;
}
}
}
if (varDeclIndex >= 0 && getterCallIndex > varDeclIndex) {
// Scan statements between var declaration and getter call
for (int i = varDeclIndex + 1; i < getterCallIndex; i++) {
org.eclipse.jdt.core.dom.ASTNode stmt = statements.get(i);
if (stmt instanceof org.eclipse.jdt.core.dom.ExpressionStatement es) {
org.eclipse.jdt.core.dom.Expression expr = es.getExpression();
// Check for setter calls: var.setField(value)
if (expr instanceof org.eclipse.jdt.core.dom.MethodInvocation mi) {
if (mi.getExpression() instanceof org.eclipse.jdt.core.dom.SimpleName sn
&& sn.getIdentifier().equals(varName)) {
String methodName = mi.getName().getIdentifier();
// Check if it's a setter (setXxx or xxx)
if (methodName.startsWith("set") && methodName.length() > 3) {
String fieldName = Character.toLowerCase(methodName.charAt(3))
+ methodName.substring(4);
if (!mi.arguments().isEmpty()) {
org.eclipse.jdt.core.dom.Expression arg = (org.eclipse.jdt.core.dom.Expression) mi.arguments().get(0);
String val = constantResolver.resolve(arg, context);
if (val != null) {
fieldValues.put(fieldName, val);
fieldValues.put("this." + fieldName, val);
}
}
}
}
}
// Check for direct field assignments: var.field = value
else if (expr instanceof org.eclipse.jdt.core.dom.Assignment assignment) {
if (assignment.getLeftHandSide() instanceof org.eclipse.jdt.core.dom.FieldAccess fa) {
if (fa.getExpression() instanceof org.eclipse.jdt.core.dom.SimpleName sn
&& sn.getIdentifier().equals(varName)) {
String fieldName = fa.getName().getIdentifier();
org.eclipse.jdt.core.dom.Expression rhs = assignment.getRightHandSide();
String val = constantResolver.resolve(rhs, context);
if (val != null) {
fieldValues.put(fieldName, val);
fieldValues.put("this." + fieldName, val);
}
}
}
}
}
}
}
}
/**
* Extracts a string representation of the receiver expression from a MethodInvocation.
* Handles ThisExpression, SimpleName, and other expression types.
*/
protected String getReceiverString(Expression receiver) {
if (receiver == null) {
return null;
}
if (receiver instanceof ThisExpression) {
return "this";
}
if (receiver instanceof SuperMethodInvocation) {
return "super";
}
if (receiver instanceof SimpleName sn) {
return sn.getIdentifier();
}
return receiver.toString();
}
/**
* Finds the receiver expression used in the caller method when calling the target method.
* Walks the caller's AST to find a MethodInvocation with the target method name
* and returns the receiver string.
*/
protected String findCallerReceiver(String callerFqn, String targetMethodFqn) {
int lastDot = callerFqn.lastIndexOf('.');
if (lastDot < 0) return null;
String callerClass = callerFqn.substring(0, lastDot);
String callerMethod = callerFqn.substring(lastDot + 1);
int targetLastDot = targetMethodFqn.lastIndexOf('.');
if (targetLastDot < 0) return null;
String targetMethodName = targetMethodFqn.substring(targetLastDot + 1);
TypeDeclaration callerTd = context.getTypeDeclaration(callerClass);
if (callerTd == null) return null;
MethodDeclaration callerMd = context.findMethodDeclaration(callerTd, callerMethod, false);
if (callerMd == null || callerMd.getBody() == null) return null;
final String[] foundReceiver = {null};
callerMd.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (foundReceiver[0] != null) return false;
String called = resolveCalledMethod(node);
if (called != null && (called.equals(targetMethodFqn) || called.endsWith("." + targetMethodName))) {
foundReceiver[0] = getReceiverString(node.getExpression());
return false;
}
return super.visit(node);
}
});
return foundReceiver[0];
}
protected String resolveMethodInvocationReturnType(MethodInvocation mi) {
Expression receiver = mi.getExpression();
String receiverType = null;

View File

@@ -46,7 +46,8 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
List<String> args = resolveArguments(node.arguments());
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
for (String calledMethod : calledMethods) {
CallEdge edge = new CallEdge(calledMethod, args);
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(calledMethod, args, receiver);
edge.setConstraint(resolveConstraint(node, calledMethod, constraint));
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
@@ -64,7 +65,8 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
} else {
implicitArgs.addAll(args);
}
CallEdge edge = new CallEdge(refMethod, implicitArgs);
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(refMethod, implicitArgs, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
@@ -85,13 +87,14 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
} else {
implicitArgs.addAll(args);
}
CallEdge edge = new CallEdge(calledMethod, implicitArgs);
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(calledMethod, implicitArgs, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) {
CallEdge implEdge = new CallEdge(impl + "." + emr.getName().getIdentifier(), args);
CallEdge implEdge = new CallEdge(impl + "." + emr.getName().getIdentifier(), args, receiver);
implEdge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(implEdge);
}
@@ -125,8 +128,9 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
calledMethod = superFqn + "." + methodName;
}
List<String> args = resolveArguments(node.arguments());
String receiver = "super";
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
CallEdge edge = new CallEdge(calledMethod, args);
CallEdge edge = new CallEdge(calledMethod, args, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
@@ -260,88 +264,6 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
return null;
}
private String resolveReceiverTypeFallback(SimpleName receiverNameNode) {
String varName = receiverNameNode.getIdentifier();
// 1. Check local variables in enclosing method
MethodDeclaration enclosingMethod = findEnclosingMethod(receiverNameNode);
if (enclosingMethod != null) {
// Check parameters
for (Object paramObj : enclosingMethod.parameters()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) paramObj;
if (svd.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(svd.getType(), receiverNameNode);
}
}
// Check method body (local variables)
if (enclosingMethod.getBody() != null) {
Type[] foundType = new Type[1];
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
foundType[0] = node.getType();
}
}
return super.visit(node);
}
});
if (foundType[0] != null) {
return resolveTypeToFqn(foundType[0], receiverNameNode);
}
}
}
// 2. Check fields in enclosing class and its superclasses
TypeDeclaration enclosingType = findEnclosingType(receiverNameNode);
TypeDeclaration currentType = enclosingType;
while (currentType != null) {
for (FieldDeclaration field : currentType.getFields()) {
for (Object fragObj : field.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(field.getType(), receiverNameNode);
}
}
}
String superclass = context.getSuperclassFqn(currentType);
currentType = superclass != null ? context.getTypeDeclaration(superclass) : null;
}
return null;
}
private String resolveTypeToFqn(Type type, ASTNode contextNode) {
if (type == null) return null;
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), contextNode);
} else {
simpleName = type.toString();
}
CompilationUnit cu = (CompilationUnit) contextNode.getRoot();
TypeDeclaration td = context.getTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
// Fallback to import matching if CodebaseContext doesn't know it (e.g., external library)
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
return simpleName;
}
private Expression unwrapMessageBuilder(Expression expr) {
if (expr instanceof MethodInvocation mi) {
MethodInvocation current = mi;

View File

@@ -31,7 +31,6 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
private final SpringComponentDetector componentDetector;
private List<TriggerPoint> cachedTriggers;
private List<EntryPoint> cachedEntryPoints;
private List<CallChain> cachedCallChains;
public JdtIntelligenceProvider(CodebaseContext context, Path rootDir) {
this.context = context;
@@ -94,17 +93,15 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
@Override
public List<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
if (cachedCallChains != null) {
return cachedCallChains;
}
List<EntryPoint> allEntryPoints = findEntryPoints();
List<TriggerPoint> allTriggers = triggers != null && !triggers.isEmpty()
List<EntryPoint> scopedEntryPoints = entryPoints != null && !entryPoints.isEmpty()
? entryPoints
: findEntryPoints();
List<TriggerPoint> scopedTriggers = triggers != null && !triggers.isEmpty()
? triggers
: findTriggerPoints();
log.info("JdtIntelligenceProvider searching for call chains between {} entry points and {} triggers",
allEntryPoints.size(), allTriggers.size());
cachedCallChains = callGraphEngine.findChains(allEntryPoints, allTriggers);
return cachedCallChains;
scopedEntryPoints.size(), scopedTriggers.size());
return callGraphEngine.findChains(scopedEntryPoints, scopedTriggers);
}
@Override

View File

@@ -23,7 +23,7 @@ public class SpringMvcDetector {
cu.accept(new ASTVisitor() {
@Override
public boolean visit(TypeDeclaration node) {
if (isRestController(node)) {
if (isRestController(node) && !node.isInterface()) {
processController(node, entryPoints);
}
// Support WebFlux RouterFunction beans

View File

@@ -7,6 +7,7 @@ import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.index.TrivialAccessorDetector;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
@@ -16,6 +17,7 @@ public class VariableTracer {
private ConstantExtractor constantExtractor;
private final click.kamil.springstatemachineexporter.ast.common.DataFlowModel dataFlowModel;
private final Map<String, String> variableDeclaredTypeCache = new HashMap<>();
public VariableTracer(CodebaseContext context, ConstantResolver constantResolver) {
this.context = context;
@@ -27,6 +29,13 @@ public class VariableTracer {
this.constantExtractor = constantExtractor;
}
public void clearAnalysisCaches() {
variableDeclaredTypeCache.clear();
if (dataFlowModel instanceof JdtDataFlowModel jdtDataFlowModel) {
jdtDataFlowModel.clearAnalysisCaches();
}
}
public Expression traceVariable(Expression expr) {
List<Expression> all = traceVariableAll(expr);
return all.isEmpty() ? expr : all.get(all.size() - 1);
@@ -159,9 +168,18 @@ public class VariableTracer {
}
private String getFieldType(TypeDeclaration td, String fieldName, CodebaseContext context, java.util.Set<String> visited) {
if (td == null) return null;
if (td == null) {
return null;
}
String typeFqn = context.getFqn(td);
if (!visited.add(typeFqn)) return null;
if (!visited.add(typeFqn)) {
return null;
}
Optional<String> indexed = context.getFieldTypeIndex().lookup(typeFqn, fieldName);
if (indexed.isPresent()) {
return indexed.get();
}
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
@@ -215,7 +233,21 @@ public class VariableTracer {
}
public String getVariableDeclaredType(String methodFqn, String cleanFieldName) {
if (methodFqn == null) return null;
if (methodFqn == null) {
return null;
}
String cacheKey = methodFqn + "|" + cleanFieldName;
String cached = variableDeclaredTypeCache.get(cacheKey);
if (cached != null) {
return cached.isEmpty() ? null : cached;
}
String resolved = resolveVariableDeclaredTypeUncached(methodFqn, cleanFieldName);
variableDeclaredTypeCache.put(cacheKey, resolved == null ? "" : resolved);
return resolved;
}
private String resolveVariableDeclaredTypeUncached(String methodFqn, String cleanFieldName) {
int lastDot = methodFqn.lastIndexOf('.');
if (lastDot > 0) {
String fqn = methodFqn.substring(0, lastDot);

View File

@@ -2,6 +2,17 @@ package click.kamil.springstatemachineexporter.ast.common;
import click.kamil.springstatemachineexporter.analysis.index.AccessorIndex;
import click.kamil.springstatemachineexporter.analysis.index.AccessorIndexBuilder;
import click.kamil.springstatemachineexporter.analysis.index.FieldTypeIndex;
import click.kamil.springstatemachineexporter.analysis.index.FieldTypeIndexBuilder;
import click.kamil.springstatemachineexporter.analysis.index.GetterChainIndex;
import click.kamil.springstatemachineexporter.analysis.index.GetterChainIndexBuilder;
import click.kamil.springstatemachineexporter.analysis.index.IndexedFieldConstantsIndex;
import click.kamil.springstatemachineexporter.analysis.index.IndexedFieldConstantsIndexBuilder;
import click.kamil.springstatemachineexporter.analysis.index.PolymorphicAccessorIndex;
import click.kamil.springstatemachineexporter.analysis.index.PolymorphicAccessorIndexBuilder;
import click.kamil.springstatemachineexporter.analysis.index.PolymorphicMethodExpansionIndex;
import click.kamil.springstatemachineexporter.analysis.index.PolymorphicMethodExpansionIndexBuilder;
import click.kamil.springstatemachineexporter.analysis.pipeline.ClassCompatibilityCache;
import click.kamil.springstatemachineexporter.analysis.model.LibraryHint;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver;
@@ -29,6 +40,7 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
public class CodebaseContext {
private final Map<String, CompilationUnit> classes = new HashMap<>();
private final Map<String, TypeDeclaration> typeDeclarations = new HashMap<>();
private final Map<String, org.eclipse.jdt.core.dom.RecordDeclaration> recordDeclarations = new HashMap<>();
private final Map<String, Path> classPaths = new HashMap<>();
private final Map<String, String> simpleNameToFqn = new HashMap<>();
@@ -47,6 +59,16 @@ public class CodebaseContext {
private Path projectRoot;
private final Map<String, Object> cache = new HashMap<>();
private AccessorIndex accessorIndex = AccessorIndex.empty();
private PolymorphicAccessorIndex polymorphicAccessorIndex = PolymorphicAccessorIndex.empty();
private GetterChainIndex getterChainIndex = GetterChainIndex.empty();
private IndexedFieldConstantsIndex indexedFieldConstantsIndex = IndexedFieldConstantsIndex.empty();
private FieldTypeIndex fieldTypeIndex = FieldTypeIndex.empty();
private PolymorphicMethodExpansionIndex polymorphicMethodExpansionIndex =
PolymorphicMethodExpansionIndex.empty();
private final ClassCompatibilityCache classCompatibilityCache = new ClassCompatibilityCache();
private final Map<String, MethodDeclaration> methodDeclarationCache = new HashMap<>();
private final Map<String, String> superclassFqnCache = new HashMap<>();
private final Map<String, List<String>> implementationsCache = new HashMap<>();
private boolean inlineAccessors = true;
public Map<String, Object> getCache() {
@@ -57,6 +79,40 @@ public class CodebaseContext {
return accessorIndex;
}
public PolymorphicAccessorIndex getPolymorphicAccessorIndex() {
return polymorphicAccessorIndex;
}
public GetterChainIndex getGetterChainIndex() {
return getterChainIndex;
}
public IndexedFieldConstantsIndex getIndexedFieldConstantsIndex() {
return indexedFieldConstantsIndex;
}
public FieldTypeIndex getFieldTypeIndex() {
return fieldTypeIndex;
}
public PolymorphicMethodExpansionIndex getPolymorphicMethodExpansionIndex() {
return polymorphicMethodExpansionIndex;
}
public ClassCompatibilityCache getClassCompatibilityCache() {
return classCompatibilityCache;
}
public void clearAnalysisCaches() {
methodDeclarationCache.clear();
superclassFqnCache.clear();
classCompatibilityCache.clear();
}
public Set<String> getIndexedTypeFqns() {
return Collections.unmodifiableSet(classes.keySet());
}
public boolean isInlineAccessors() {
return inlineAccessors;
}
@@ -241,6 +297,7 @@ public class CodebaseContext {
List<Path> javaFiles = FileUtils.findFilesWithExtension(rootDirs, ".java", activeIgnore);
log.info("CodebaseContext found {} java files across {} root directories", javaFiles.size(), rootDirs.size());
typeDeclarations.clear();
for (Path javaFile : javaFiles) {
String source = Files.readString(javaFile);
CompilationUnit cu = parse(source, javaFile.toAbsolutePath().toString());
@@ -258,9 +315,22 @@ public class CodebaseContext {
}
}
this.implementationsCache.clear();
this.accessorIndex = inlineAccessors
? new AccessorIndexBuilder().build(this)
: AccessorIndex.empty();
this.polymorphicAccessorIndex = inlineAccessors
? new PolymorphicAccessorIndexBuilder().build(this, accessorIndex)
: PolymorphicAccessorIndex.empty();
this.getterChainIndex = inlineAccessors
? new GetterChainIndexBuilder().build(this, accessorIndex)
: GetterChainIndex.empty();
this.indexedFieldConstantsIndex = inlineAccessors
? new IndexedFieldConstantsIndexBuilder().build(this, accessorIndex)
: IndexedFieldConstantsIndex.empty();
this.fieldTypeIndex = new FieldTypeIndexBuilder().build(this);
this.polymorphicMethodExpansionIndex = new PolymorphicMethodExpansionIndexBuilder().build(this);
clearAnalysisCaches();
if (inlineAccessors) {
log.info("Built accessor index: {} trivial accessors across {} types",
accessorIndex.trivialCount(), accessorIndex.typeCount());
@@ -275,6 +345,7 @@ public class CodebaseContext {
classes.put(fqn, (CompilationUnit) td.getRoot());
classPaths.put(fqn, javaFile);
typeDeclarations.put(fqn, td);
if (simpleNameToFqn.containsKey(simpleName)) {
String existingFqn = simpleNameToFqn.get(simpleName);
@@ -343,15 +414,21 @@ public class CodebaseContext {
}
public List<String> getImplementations(String interfaceName) {
String cleanName = interfaceName;
if (interfaceName != null && interfaceName.contains("<")) {
cleanName = interfaceName.substring(0, interfaceName.indexOf('<'));
if (interfaceName == null) {
return List.of();
}
String cleanName = interfaceName.contains("<")
? interfaceName.substring(0, interfaceName.indexOf('<'))
: interfaceName;
return implementationsCache.computeIfAbsent(cleanName, this::computeImplementations);
}
private List<String> computeImplementations(String cleanName) {
Set<String> allImpls = new LinkedHashSet<>();
collectImplementations(cleanName, allImpls, new HashSet<>());
List<String> sorted = new ArrayList<>(allImpls);
Collections.sort(sorted);
return sorted;
return Collections.unmodifiableList(sorted);
}
private void collectImplementations(String typeName, Set<String> results, Set<String> visited) {
@@ -465,10 +542,20 @@ public class CodebaseContext {
}
public TypeDeclaration getTypeDeclaration(String name) {
TypeDeclaration cached = typeDeclarations.get(name);
if (cached != null) {
return cached;
}
// Try exact FQN match first
CompilationUnit cu = classes.get(name);
if (cu != null)
return findTypeInCu(cu, name);
if (cu != null) {
TypeDeclaration resolved = findTypeInCu(cu, name);
if (resolved != null) {
typeDeclarations.put(name, resolved);
}
return resolved;
}
// If it's a simple name, check if it's ambiguous
if (ambiguousSimpleNames.contains(name)) {
@@ -479,9 +566,18 @@ public class CodebaseContext {
// If not ambiguous, return the one we found during scan
String fqn = simpleNameToFqn.get(name);
if (fqn != null) {
cached = typeDeclarations.get(fqn);
if (cached != null) {
return cached;
}
cu = classes.get(fqn);
if (cu != null)
return findTypeInCu(cu, fqn);
if (cu != null) {
TypeDeclaration resolved = findTypeInCu(cu, fqn);
if (resolved != null) {
typeDeclarations.put(fqn, resolved);
}
return resolved;
}
}
return null;
@@ -541,7 +637,21 @@ public class CodebaseContext {
}
public String getSuperclassFqn(TypeDeclaration td) {
if (td == null) return null;
if (td == null) {
return null;
}
String typeFqn = getFqn(td);
if (typeFqn != null && superclassFqnCache.containsKey(typeFqn)) {
return superclassFqnCache.get(typeFqn);
}
String resolved = resolveSuperclassFqn(td);
if (typeFqn != null) {
superclassFqnCache.put(typeFqn, resolved);
}
return resolved;
}
private String resolveSuperclassFqn(TypeDeclaration td) {
ITypeBinding binding = td.resolveBinding();
if (binding != null) {
ITypeBinding superBinding = binding.getSuperclass();
@@ -571,7 +681,23 @@ public class CodebaseContext {
}
public MethodDeclaration findMethodDeclaration(TypeDeclaration td, String methodName, boolean includeSuper) {
if (td == null) return null;
if (td == null) {
return null;
}
String typeFqn = getFqn(td);
if (typeFqn != null) {
String cacheKey = typeFqn + "#" + methodName + "#" + includeSuper;
if (methodDeclarationCache.containsKey(cacheKey)) {
return methodDeclarationCache.get(cacheKey);
}
MethodDeclaration resolved = findMethodDeclarationUncached(td, methodName, includeSuper);
methodDeclarationCache.put(cacheKey, resolved);
return resolved;
}
return findMethodDeclarationUncached(td, methodName, includeSuper);
}
private MethodDeclaration findMethodDeclarationUncached(TypeDeclaration td, String methodName, boolean includeSuper) {
ITypeBinding binding = td.resolveBinding();
if (binding != null && includeSuper) {
IMethodBinding methodBinding = findMethodBinding(binding, methodName);
@@ -650,6 +776,44 @@ public class CodebaseContext {
return null;
}
public boolean areClassesPolymorphicallyCompatible(String classNeighbor, String classTarget) {
if (classNeighbor == null || classTarget == null) {
return false;
}
if (classNeighbor.equals(classTarget)) {
return true;
}
String simpleClassNeighbor = simpleTypeName(classNeighbor);
String simpleClassTarget = simpleTypeName(classTarget);
if (simpleClassNeighbor.equals(simpleClassTarget)) {
return true;
}
return classCompatibilityCache.areClassesCompatible(
classNeighbor,
classTarget,
this::computePolymorphicClassCompatibility);
}
private boolean computePolymorphicClassCompatibility(String classNeighbor, String classTarget) {
List<String> targetImplementations = getImplementations(classTarget);
for (String implementation : targetImplementations) {
if (implementation.equals(classNeighbor) || implementation.endsWith("." + classNeighbor)) {
return true;
}
}
List<String> neighborImplementations = getImplementations(classNeighbor);
for (String implementation : neighborImplementations) {
if (implementation.equals(classTarget) || implementation.endsWith("." + classTarget)) {
return true;
}
}
return false;
}
private static String simpleTypeName(String typeFqn) {
return typeFqn.contains(".") ? typeFqn.substring(typeFqn.lastIndexOf('.') + 1) : typeFqn;
}
public org.eclipse.jdt.core.dom.RecordDeclaration getRecordDeclaration(String fqn) {
if (fqn == null || fqn.isEmpty()) {
return null;

View File

@@ -14,6 +14,7 @@ public class JdtDataFlowModel implements DataFlowModel {
private final Map<MethodDeclaration, ControlFlowGraph> cfgCache = new HashMap<>();
private final Map<MethodDeclaration, ReachingDefinitions> rdCache = new HashMap<>();
private final Map<ASTNode, Map<IVariableBinding, Expression>> objectFieldBindings = new HashMap<>();
private final Map<String, Set<String>> compatibleContextTypesCache = new HashMap<>();
private static final ThreadLocal<List<String>> CURRENT_PATH = new ThreadLocal<>();
@@ -33,6 +34,10 @@ public class JdtDataFlowModel implements DataFlowModel {
this.context = context;
}
public void clearAnalysisCaches() {
compatibleContextTypesCache.clear();
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode current = node;
while (current != null) {
@@ -1514,6 +1519,12 @@ public class JdtDataFlowModel implements DataFlowModel {
}
private Set<String> getCompatibleContextTypes(List<String> currentPath, String declaringClassFqn) {
String cacheKey = String.valueOf(currentPath) + "#" + declaringClassFqn;
return compatibleContextTypesCache.computeIfAbsent(
cacheKey, ignored -> computeCompatibleContextTypes(currentPath, declaringClassFqn));
}
private Set<String> computeCompatibleContextTypes(List<String> currentPath, String declaringClassFqn) {
Set<String> contextTypes = new HashSet<>();
List<String> implementations = context.getImplementations(declaringClassFqn);