Resolve inheritance call targets and cap polymorphic event transitions.

Add InheritanceCallTargetResolver for super/this/implicit dispatch across
call-graph engines, apply transition-ceiling to polymorphic events in
MachineEnumCanonicalizer, and cover JDT parity, deep hierarchy, and
pipeline behavior with new tests plus golden update.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-12 19:46:48 +02:00
parent e6effa3dcd
commit 2f853fb971
13 changed files with 900 additions and 124 deletions

View File

@@ -95,6 +95,34 @@ public final class EnumMemberPredicateEvaluator {
return filtered;
}
/**
* True when every predicate method in the constraint exists on the enum (or its interfaces).
* Used to distinguish inconclusive filtering from an intentional empty result.
*/
public static boolean hasResolvablePredicateMethods(
String constraint,
String enumTypeFqn,
CodebaseContext context) {
List<PredicateCall> predicates = extractPredicateCalls(constraint);
if (predicates.isEmpty()) {
return true;
}
EnumDeclaration enumDecl = findEnumDeclaration(enumTypeFqn, context);
if (enumDecl == null) {
return false;
}
for (PredicateCall predicate : predicates) {
MethodDeclaration method = findParameterlessMethod(enumDecl, predicate.methodName());
if (method == null && context != null) {
method = findInterfaceParameterlessMethod(enumDecl, predicate.methodName(), context);
}
if (method == null) {
return false;
}
}
return true;
}
private static boolean satisfiesPredicates(
String canonicalConstantFqn,
List<PredicateCall> predicates,

View File

@@ -101,6 +101,14 @@ public final class MachineEnumCanonicalizer {
List<String> polymorphicEvents,
String machineEventTypeFqn,
CodebaseContext context) {
return expandSymbolicPolymorphicEvents(polymorphicEvents, machineEventTypeFqn, context, null);
}
public static List<String> expandSymbolicPolymorphicEvents(
List<String> polymorphicEvents,
String machineEventTypeFqn,
CodebaseContext context,
List<Transition> machineTransitions) {
if (polymorphicEvents == null || polymorphicEvents.isEmpty()) {
return polymorphicEvents;
}
@@ -108,6 +116,8 @@ public final class MachineEnumCanonicalizer {
return polymorphicEvents;
}
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, machineEventTypeFqn);
List<String> expanded = new ArrayList<>();
for (String pe : polymorphicEvents) {
if (pe == null) {
@@ -118,6 +128,10 @@ public final class MachineEnumCanonicalizer {
if (!enumTypesMatch(machineEventTypeFqn, symbolicType)) {
continue;
}
if (!transitionEvents.isEmpty()) {
expanded.addAll(transitionEvents);
continue;
}
if (context != null) {
List<String> enumValues = context.getEnumValues(stripGenerics(machineEventTypeFqn));
if (enumValues != null) {
@@ -202,7 +216,7 @@ public final class MachineEnumCanonicalizer {
List<Transition> machineTransitions,
boolean skipCanonicalization) {
TriggerPoint expanded = skipCanonicalization
? expandSymbolicOnly(trigger, machineTypes, context)
? expandSymbolicOnly(trigger, machineTypes, context, machineTransitions)
: canonicalizeAndExpandTriggerPoint(trigger, machineTypes, context);
if (expanded == null || machineTypes == null || machineTypes.eventTypeFqn() == null) {
return expanded;
@@ -210,7 +224,7 @@ public final class MachineEnumCanonicalizer {
List<String> postExpand = expanded.getPolymorphicEvents();
if (postExpand != null && postExpand.stream().anyMatch(pe -> pe != null && pe.startsWith("<SYMBOLIC:"))) {
postExpand = expandSymbolicPolymorphicEvents(
postExpand, machineTypes.eventTypeFqn(), context);
postExpand, machineTypes.eventTypeFqn(), context, machineTransitions);
postExpand = narrowExpandedPolymorphicEvents(
postExpand,
expanded.getConstraint(),
@@ -221,7 +235,8 @@ public final class MachineEnumCanonicalizer {
expanded = expanded.toBuilder().polymorphicEvents(postExpand).build();
}
}
if (hasConcretePolymorphicEvents(expanded.getPolymorphicEvents())) {
if (hasConcretePolymorphicEvents(expanded.getPolymorphicEvents())
|| (expanded.getPolymorphicEvents() != null && !expanded.getPolymorphicEvents().isEmpty())) {
List<String> narrowed = narrowPolymorphicCandidates(
expanded.getPolymorphicEvents(),
expanded.getConstraint(),
@@ -255,12 +270,13 @@ public final class MachineEnumCanonicalizer {
private static TriggerPoint expandSymbolicOnly(
TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context) {
CodebaseContext context,
List<Transition> machineTransitions) {
if (trigger == null || machineTypes == null || machineTypes.eventTypeFqn() == null) {
return trigger;
}
List<String> expanded = expandSymbolicPolymorphicEvents(
trigger.getPolymorphicEvents(), machineTypes.eventTypeFqn(), context);
trigger.getPolymorphicEvents(), machineTypes.eventTypeFqn(), context, machineTransitions);
if (java.util.Objects.equals(expanded, trigger.getPolymorphicEvents())) {
return trigger;
}
@@ -315,21 +331,21 @@ public final class MachineEnumCanonicalizer {
List<Transition> machineTransitions,
CodebaseContext context) {
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn);
if (!transitionEvents.isEmpty()) {
List<String> filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
transitionEvents, constraint, eventTypeFqn, context);
return filtered.isEmpty() ? transitionEvents : filtered;
if (transitionEvents.isEmpty()) {
return List.of();
}
List<String> enumConstants = allPackageCanonicalEnumConstants(eventTypeFqn, context);
List<String> filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
enumConstants, constraint, eventTypeFqn, context);
transitionEvents, constraint, eventTypeFqn, context);
if (!filtered.isEmpty()) {
return filtered;
}
if (!transitionEvents.isEmpty()) {
if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(constraint)) {
if (EnumMemberPredicateEvaluator.hasResolvablePredicateMethods(constraint, eventTypeFqn, context)) {
return List.of();
}
return transitionEvents;
}
return List.of();
return transitionEvents;
}
private static List<String> narrowPolymorphicCandidates(
@@ -339,56 +355,65 @@ public final class MachineEnumCanonicalizer {
List<Transition> machineTransitions,
CodebaseContext context) {
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn);
List<String> result = EnumMemberPredicateEvaluator.filterEnumConstants(
current, constraint, eventTypeFqn, context);
if (result.isEmpty() && !transitionEvents.isEmpty()) {
result = new ArrayList<>(transitionEvents);
}
if (!looksOverBroad(result, transitionEvents, constraint)) {
return result;
}
if (!transitionEvents.isEmpty()) {
List<String> intersected = new ArrayList<>();
for (String event : result) {
if (transitionEvents.contains(event)) {
intersected.add(event);
List<String> result = current == null ? List.of() : new ArrayList<>(current);
if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(constraint)) {
List<String> filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
result, constraint, eventTypeFqn, context);
if (!filtered.isEmpty()) {
result = filtered;
} else if (!transitionEvents.isEmpty()) {
filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
transitionEvents, constraint, eventTypeFqn, context);
if (!filtered.isEmpty()) {
result = filtered;
} else if (EnumMemberPredicateEvaluator.hasResolvablePredicateMethods(
constraint, eventTypeFqn, context)) {
result = List.of();
} else {
result = transitionEvents;
}
} else {
result = List.of();
}
if (!intersected.isEmpty()) {
return intersected;
}
List<String> transitionFiltered = EnumMemberPredicateEvaluator.filterEnumConstants(
transitionEvents, constraint, eventTypeFqn, context);
return transitionFiltered.isEmpty() ? transitionEvents : transitionFiltered;
}
if (!transitionEvents.isEmpty()) {
result = capToConfiguredTransitionEvents(result, transitionEvents);
}
return result;
}
private static boolean looksOverBroad(
List<String> current,
List<String> transitionEvents,
String constraint) {
if (current == null || current.isEmpty()) {
return false;
/**
* Fail-closed ceiling: polymorphic candidates must never exceed events configured on the machine.
*/
static List<String> capToConfiguredTransitionEvents(
List<String> candidates,
List<String> transitionEvents) {
if (transitionEvents == null || transitionEvents.isEmpty()) {
return candidates == null ? List.of() : candidates;
}
if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(constraint)) {
return true;
if (candidates == null || candidates.isEmpty()) {
return List.of();
}
if (current.size() <= 1) {
return false;
}
if (transitionEvents.isEmpty()) {
return current.size() > 1;
}
if (current.size() > transitionEvents.size()) {
return true;
}
for (String event : current) {
if (!transitionEvents.contains(event)) {
return true;
List<String> capped = new ArrayList<>();
for (String candidate : candidates) {
if (candidate == null) {
continue;
}
if (transitionEvents.contains(candidate)) {
capped.add(candidate);
continue;
}
String constant = constantName(candidate);
for (String transitionEvent : transitionEvents) {
if (constantName(transitionEvent).equals(constant)) {
capped.add(transitionEvent);
break;
}
}
}
return false;
return capped;
}
public static List<String> polymorphicEventsFromTransitions(
@@ -423,23 +448,6 @@ public final class MachineEnumCanonicalizer {
.anyMatch(pe -> classifyTriggerEvent(pe) == TriggerEventKind.CANONICAL_ENUM);
}
private static List<String> allPackageCanonicalEnumConstants(String eventTypeFqn, CodebaseContext context) {
if (eventTypeFqn == null || eventTypeFqn.isBlank() || context == null) {
return List.of();
}
String enumType = stripGenerics(eventTypeFqn);
List<String> enumValues = context.getEnumValues(enumType);
if (enumValues == null || enumValues.isEmpty()) {
return List.of();
}
List<String> canonical = new ArrayList<>();
for (String value : enumValues) {
String constant = value.contains(".") ? value.substring(value.lastIndexOf('.') + 1) : value;
canonical.add(enumType + "." + constant);
}
return canonical;
}
public static String qualifyEventIdentifier(String event, String eventTypeFqn) {
if (event == null || eventTypeFqn == null || event.isEmpty()) {
return event;

View File

@@ -971,14 +971,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
polymorphicEvents.add(methodReturn);
}
}
if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context)
&& polymorphicEvents.stream().anyMatch(pe -> pe != null && pe.startsWith("<SYMBOLIC:"))
&& declaredType != null) {
List<String> expandedEnumValues = expandDeclaredEnumValues(declaredType);
if (!expandedEnumValues.isEmpty()) {
polymorphicEvents = new ArrayList<>(expandedEnumValues);
}
}
}
}
}
@@ -2403,26 +2395,16 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (tdOuter != null) {
String callerFqn = context.getFqn(tdOuter) + "." + md.getName().getIdentifier();
String methodName = node.getName().getIdentifier();
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
String calledMethod = null;
if (superTd != null) {
calledMethod = resolveMethodInType(superTd, methodName);
}
if (calledMethod == null) {
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, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(callerFqn, k -> new ArrayList<>()).add(edge);
}
String calledMethod = InheritanceCallTargetResolver.resolveSuperMethod(
context, tdOuter, methodName);
if (calledMethod != null) {
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, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(callerFqn, k -> new ArrayList<>()).add(edge);
}
}
}

View File

@@ -28,12 +28,14 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
}
}
TypeDeclaration enclosingType = findEnclosingType(node);
if (receiver instanceof SuperMethodInvocation) {
return InheritanceCallTargetResolver.resolveSuperMethod(context, enclosingType, methodName);
}
if (receiver == null) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return resolveMethodInTypeHierarchy(td, methodName);
}
return null;
return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName);
}
ITypeBinding binding = receiver.resolveTypeBinding();
@@ -42,10 +44,7 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
}
if (receiver instanceof ThisExpression) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return context.getFqn(td) + "." + methodName;
}
return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName);
}
if (receiver instanceof SimpleName sn) {

View File

@@ -0,0 +1,65 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
/**
* Resolves call-graph target FQNs for {@code this}, implicit, and {@code super} dispatch hops.
* Uses the declaring type of the resolved method (parent class for inherited members).
*/
final class InheritanceCallTargetResolver {
private InheritanceCallTargetResolver() {
}
static String resolveInstanceMethod(
CodebaseContext context,
TypeDeclaration enclosingType,
String methodName) {
if (context == null || enclosingType == null || methodName == null || methodName.isBlank()) {
return null;
}
MethodDeclaration method = context.findMethodDeclaration(enclosingType, methodName, true);
if (method == null) {
return null;
}
TypeDeclaration declaringType = findEnclosingTypeDeclaration(method);
if (declaringType == null) {
return null;
}
String fqn = context.getFqn(declaringType);
return fqn == null ? null : fqn + "." + methodName;
}
static String resolveSuperMethod(
CodebaseContext context,
TypeDeclaration enclosingType,
String methodName) {
if (context == null || enclosingType == null || methodName == null || methodName.isBlank()) {
return null;
}
String superFqn = context.getSuperclassFqn(enclosingType);
if (superFqn == null) {
return null;
}
TypeDeclaration superType = context.getTypeDeclaration(superFqn);
if (superType == null) {
return superFqn + "." + methodName;
}
String resolved = resolveInstanceMethod(context, superType, methodName);
return resolved != null ? resolved : superFqn + "." + methodName;
}
private static TypeDeclaration findEnclosingTypeDeclaration(ASTNode node) {
ASTNode current = node;
while (current != null) {
if (current instanceof TypeDeclaration typeDeclaration) {
return typeDeclaration;
}
current = current.getParent();
}
return null;
}
}

View File

@@ -56,12 +56,14 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
}
}
TypeDeclaration enclosingType = findEnclosingType(node);
if (receiver instanceof SuperMethodInvocation) {
return InheritanceCallTargetResolver.resolveSuperMethod(context, enclosingType, methodName);
}
if (receiver == null) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return resolveMethodInType(td, methodName);
}
return null;
return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName);
}
if (injectionAnalyzer != null) {
@@ -122,10 +124,7 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
}
if (receiver instanceof ThisExpression) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return context.getFqn(td) + "." + methodName;
}
return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName);
}
if (receiver instanceof SimpleName sn) {