This commit is contained in:
2026-07-17 18:14:24 +02:00
parent e70e11ba96
commit 5081715e13
15 changed files with 4266 additions and 182 deletions

View File

@@ -0,0 +1,118 @@
package click.kamil.springstatemachineexporter.analysis.flow;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Shared binder: {@link CallEdge} argument text → unique AST argument expression in the caller body.
* Fail-closed when zero or multiple sites match (no pick-any).
* Used by allocation facts and event-identity string forwarding.
*/
public final class CallSiteArgBinder {
private CallSiteArgBinder() {
}
/**
* @return the unique caller AST argument for {@code edge}'s {@code paramIndex}, or empty
*/
public static Optional<Expression> findUniqueArgument(
MethodDeclaration callerBody,
CallEdge edge,
int paramIndex) {
if (callerBody == null || callerBody.getBody() == null || edge == null || edge.getTargetMethod() == null) {
return Optional.empty();
}
if (paramIndex < 0) {
return Optional.empty();
}
String targetSimple = methodNameOf(edge.getTargetMethod());
if (targetSimple == null || targetSimple.isBlank()) {
return Optional.empty();
}
String edgeArgText = null;
if (edge.getArguments() != null && paramIndex < edge.getArguments().size()) {
edgeArgText = normalizeExprText(edge.getArguments().get(paramIndex));
}
List<Expression> matches = new ArrayList<>();
callerBody.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (!targetSimple.equals(node.getName().getIdentifier())) {
return super.visit(node);
}
if (paramIndex >= node.arguments().size()) {
return super.visit(node);
}
matches.add((Expression) node.arguments().get(paramIndex));
return super.visit(node);
}
});
if (matches.isEmpty()) {
return Optional.empty();
}
if (matches.size() == 1) {
return Optional.of(matches.get(0));
}
if (edgeArgText == null || edgeArgText.isBlank()) {
return Optional.empty();
}
List<Expression> textHits = new ArrayList<>();
for (Expression candidate : matches) {
if (edgeArgText.equals(normalizeExprText(candidate.toString()))) {
textHits.add(candidate);
}
}
if (textHits.isEmpty()) {
return Optional.empty();
}
// Identical arg text at several sites is equivalent — pick the first.
// Differing texts that somehow normalize equal still share one edge string.
return Optional.of(textHits.get(0));
}
/**
* Whether two method FQNs refer to the same simple method (path hop matching).
*/
public static boolean methodsMatch(String left, String right) {
if (left == null || right == null) {
return false;
}
if (left.equals(right)) {
return true;
}
String leftSimple = methodNameOf(left);
String rightSimple = methodNameOf(right);
return leftSimple != null && leftSimple.equals(rightSimple)
&& (left.endsWith("." + rightSimple) || right.endsWith("." + leftSimple));
}
public static String methodNameOf(String methodFqn) {
if (methodFqn == null) {
return null;
}
int lastDot = methodFqn.lastIndexOf('.');
return lastDot >= 0 ? methodFqn.substring(lastDot + 1) : methodFqn;
}
public static String normalizeExprText(String text) {
if (text == null) {
return null;
}
return text.replaceAll("\\s+", "");
}
public static String normalizeExprText(Expression expression) {
if (expression == null) {
return null;
}
return normalizeExprText(expression.toString());
}
}

View File

@@ -1,9 +1,11 @@
package click.kamil.springstatemachineexporter.analysis.flow.alloc; package click.kamil.springstatemachineexporter.analysis.flow.alloc;
import click.kamil.springstatemachineexporter.analysis.flow.CallSiteArgBinder;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge; import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer; import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
import click.kamil.springstatemachineexporter.analysis.service.VariableTracer; import click.kamil.springstatemachineexporter.analysis.service.VariableTracer;
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
import click.kamil.springstatemachineexporter.ast.common.AstUtils; import click.kamil.springstatemachineexporter.ast.common.AstUtils;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTNode; import org.eclipse.jdt.core.dom.ASTNode;
@@ -13,16 +15,21 @@ import org.eclipse.jdt.core.dom.CastExpression;
import org.eclipse.jdt.core.dom.ClassInstanceCreation; import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.Expression; import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.FieldAccess; import org.eclipse.jdt.core.dom.FieldAccess;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.IBinding;
import org.eclipse.jdt.core.dom.IMethodBinding; import org.eclipse.jdt.core.dom.IMethodBinding;
import org.eclipse.jdt.core.dom.ITypeBinding; import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.IVariableBinding;
import org.eclipse.jdt.core.dom.MethodDeclaration; import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation; import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.Modifier;
import org.eclipse.jdt.core.dom.Name; import org.eclipse.jdt.core.dom.Name;
import org.eclipse.jdt.core.dom.ParenthesizedExpression; import org.eclipse.jdt.core.dom.ParenthesizedExpression;
import org.eclipse.jdt.core.dom.SimpleName; import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.SingleVariableDeclaration; import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
import org.eclipse.jdt.core.dom.ThisExpression; import org.eclipse.jdt.core.dom.ThisExpression;
import org.eclipse.jdt.core.dom.TypeDeclaration; import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
@@ -33,6 +40,8 @@ import java.util.Set;
/** /**
* Path-scoped allocation fact propagator: CIC → locals → callee parameters via call-edge args, * Path-scoped allocation fact propagator: CIC → locals → callee parameters via call-edge args,
* factory {@code return new T()}, and trivial getter→field→ctor-arg peels. * factory {@code return new T()}, and trivial getter→field→ctor-arg peels.
* Optionally treats a unique Spring {@code @Bean}/component as an alloc substitute when
* {@link InjectionPointAnalyzer} is wired (multi-bean / qualifier conflict → empty).
* Fail-closed when facts are empty or ambiguous (multi-site). * Fail-closed when facts are empty or ambiguous (multi-site).
*/ */
public final class AllocationFactPropagator { public final class AllocationFactPropagator {
@@ -42,6 +51,7 @@ public final class AllocationFactPropagator {
private final CodebaseContext context; private final CodebaseContext context;
private final VariableTracer variableTracer; private final VariableTracer variableTracer;
private final ConstructorAnalyzer constructorAnalyzer; private final ConstructorAnalyzer constructorAnalyzer;
private InjectionPointAnalyzer injectionAnalyzer;
public AllocationFactPropagator(CodebaseContext context, VariableTracer variableTracer) { public AllocationFactPropagator(CodebaseContext context, VariableTracer variableTracer) {
this(context, variableTracer, null); this(context, variableTracer, null);
@@ -56,19 +66,25 @@ public final class AllocationFactPropagator {
this.constructorAnalyzer = constructorAnalyzer; this.constructorAnalyzer = constructorAnalyzer;
} }
public void setInjectionAnalyzer(InjectionPointAnalyzer injectionAnalyzer) {
this.injectionAnalyzer = injectionAnalyzer;
}
/** /**
* Allocation facts for {@code expression} within {@code enclosingMethod}, optionally * Allocation facts for {@code expression} within {@code enclosingMethod}, optionally
* refined by walking {@code methodPath} call edges backward for parameter receivers. * refined by walking {@code methodPath} call edges backward for parameter receivers.
* Dominating {@code instanceof} / pattern narrowing is merged via {@link InstanceofNarrower}.
*/ */
public ReceiverFacts factsAt( public ReceiverFacts factsAt(
Expression expression, Expression expression,
MethodDeclaration enclosingMethod, MethodDeclaration enclosingMethod,
List<String> methodPath, List<String> methodPath,
Map<String, List<CallEdge>> callGraph) { Map<String, List<CallEdge>> callGraph) {
return factsAt(expression, enclosingMethod, methodPath, callGraph, 0, new LinkedHashSet<>()); ReceiverFacts base = factsAtRaw(expression, enclosingMethod, methodPath, callGraph, 0, new LinkedHashSet<>());
return InstanceofNarrower.refine(base, expression, expression, context);
} }
private ReceiverFacts factsAt( private ReceiverFacts factsAtRaw(
Expression expression, Expression expression,
MethodDeclaration enclosingMethod, MethodDeclaration enclosingMethod,
List<String> methodPath, List<String> methodPath,
@@ -116,7 +132,7 @@ public final class AllocationFactPropagator {
// Follow aliases / casts / factories: RichEvent carrier = e; e = (RichEvent) o; // Follow aliases / casts / factories: RichEvent carrier = e; e = (RichEvent) o;
String defKey = "alias:" + System.identityHashCode(defPeeled); String defKey = "alias:" + System.identityHashCode(defPeeled);
if (visited.add(defKey)) { if (visited.add(defKey)) {
ReceiverFacts nested = factsAt( ReceiverFacts nested = factsAtRaw(
defPeeled, enclosingMethod, methodPath, callGraph, depth + 1, visited); defPeeled, enclosingMethod, methodPath, callGraph, depth + 1, visited);
sites.addAll(nested.sites()); sites.addAll(nested.sites());
} }
@@ -146,11 +162,62 @@ public final class AllocationFactPropagator {
} }
} }
// Instance field: unique path-scoped write this.f = provenParam/CIC (or setF(...)).
ReceiverFacts fromField = factsFromInstanceFieldStore(
peeled, enclosingMethod, methodPath, callGraph, depth, visited);
if (!fromField.isEmpty()) {
return fromField;
}
// Unique Spring bean injected into an interface/abstract field (when analyzer is wired).
ReceiverFacts fromBean = factsFromUniqueInjectedBean(peeled);
if (!fromBean.isEmpty()) {
return fromBean;
}
return ReceiverFacts.empty(); return ReceiverFacts.empty();
} }
private ReceiverFacts factsFromUniqueInjectedBean(Expression expression) {
if (injectionAnalyzer == null || expression == null) {
return ReceiverFacts.empty();
}
IVariableBinding varBinding = null;
Expression peeled = peel(expression);
if (peeled instanceof SimpleName sn) {
IBinding binding = sn.resolveBinding();
if (binding instanceof IVariableBinding vb) {
varBinding = vb;
}
} else if (peeled instanceof FieldAccess fa) {
varBinding = fa.resolveFieldBinding();
}
if (varBinding == null) {
return ReceiverFacts.empty();
}
String concreteFqn = injectionAnalyzer.resolveInjectedBeanFqn(varBinding);
if (concreteFqn == null || concreteFqn.isBlank()) {
return ReceiverFacts.empty();
}
TypeDeclaration td = context.getTypeDeclaration(concreteFqn);
if (td == null) {
String simple = concreteFqn.contains(".")
? concreteFqn.substring(concreteFqn.lastIndexOf('.') + 1)
: concreteFqn;
td = context.getTypeDeclaration(simple);
if (td != null) {
concreteFqn = context.getFqn(td);
}
}
if (td == null || td.isInterface() || Modifier.isAbstract(td.getModifiers())) {
return ReceiverFacts.empty();
}
return ReceiverFacts.of(new AllocationSite(concreteFqn));
}
/** /**
* Facts for the receiver of a virtual accessor call ({@code e.getType()}). * Facts for the receiver of a virtual accessor call ({@code e.getType()}).
* Uses the accessor invocation as instanceof use-site so then-branch narrowing applies.
*/ */
public ReceiverFacts factsForAccessorReceiver( public ReceiverFacts factsForAccessorReceiver(
MethodInvocation accessorCall, MethodInvocation accessorCall,
@@ -164,7 +231,9 @@ public final class AllocationFactPropagator {
return ReceiverFacts.empty(); return ReceiverFacts.empty();
} }
MethodDeclaration enclosing = findEnclosingMethod(accessorCall); MethodDeclaration enclosing = findEnclosingMethod(accessorCall);
return factsAt(receiver, enclosing, methodPath, callGraph); ReceiverFacts base = factsAtRaw(
receiver, enclosing, methodPath, callGraph, 0, new LinkedHashSet<>());
return InstanceofNarrower.refine(base, receiver, accessorCall, context);
} }
private ReceiverFacts factsFromFactoryReturn(MethodInvocation mi, int depth, Set<String> visited) { private ReceiverFacts factsFromFactoryReturn(MethodInvocation mi, int depth, Set<String> visited) {
@@ -207,22 +276,238 @@ public final class AllocationFactPropagator {
return ReceiverFacts.empty(); return ReceiverFacts.empty();
} }
try { try {
ReceiverFacts receiverFacts = factsAt( ReceiverFacts receiverFacts = factsAtRaw(
mi.getExpression(), enclosingMethod, methodPath, callGraph, depth + 1, visited); mi.getExpression(), enclosingMethod, methodPath, callGraph, depth + 1, visited);
AllocationSite unique = receiverFacts.uniqueOrNull(); AllocationSite unique = receiverFacts.uniqueOrNull();
if (unique == null || unique.cic() == null) { if (unique != null && unique.cic() != null) {
return ReceiverFacts.empty(); Expression ctorArg = resolveGetterToCtorArg(unique, mi.getName().getIdentifier());
if (ctorArg != null) {
return factsAtRaw(ctorArg, enclosingMethod, methodPath, callGraph, depth + 1, visited);
}
} }
Expression ctorArg = resolveGetterToCtorArg(unique, mi.getName().getIdentifier()); // Trivial setter peel: w.setEvent(new T()); … w.getEvent()
if (ctorArg == null) { String fieldName = javaBeansFieldName(mi.getName().getIdentifier());
return ReceiverFacts.empty(); ReceiverFacts fromSetter = factsFromUniqueSetterWrite(
mi.getExpression(),
fieldName,
mi.getName().getIdentifier(),
enclosingMethod,
methodPath,
callGraph,
depth,
visited);
if (!fromSetter.isEmpty()) {
return fromSetter;
} }
return factsAt(ctorArg, enclosingMethod, methodPath, callGraph, depth + 1, visited); return ReceiverFacts.empty();
} finally { } finally {
visited.remove(peelKey); visited.remove(peelKey);
} }
} }
/**
* Path-scoped instance field store: {@code this.carrier = e} or {@code setCarrier(e)} with a
* unique proven RHS on methods appearing on {@code methodPath}.
*/
private ReceiverFacts factsFromInstanceFieldStore(
Expression peeled,
MethodDeclaration enclosingMethod,
List<String> methodPath,
Map<String, List<CallEdge>> callGraph,
int depth,
Set<String> visited) {
if (enclosingMethod == null || methodPath == null || depth > MAX_DEPTH) {
return ReceiverFacts.empty();
}
String fieldName = instanceFieldName(peeled);
if (fieldName == null) {
return ReceiverFacts.empty();
}
TypeDeclaration owner = findEnclosingType(enclosingMethod);
if (owner == null || !ownerDeclaresField(owner, fieldName)) {
return ReceiverFacts.empty();
}
String visitKey = "fieldStore:" + context.getFqn(owner) + "#" + fieldName;
if (!visited.add(visitKey)) {
return ReceiverFacts.empty();
}
try {
LinkedHashSet<AllocationSite> sites = new LinkedHashSet<>();
int writeCount = 0;
for (String hop : methodPath) {
if (hop == null || !hopContainsType(hop, owner)) {
continue;
}
MethodDeclaration md = findMethodDeclaration(hop);
if (md == null || md.getBody() == null) {
continue;
}
List<Expression> rhsList = collectFieldWrites(md, fieldName);
for (Expression rhs : rhsList) {
writeCount++;
ReceiverFacts nested = factsAtRaw(rhs, md, methodPath, callGraph, depth + 1, visited);
sites.addAll(nested.sites());
}
}
if (writeCount == 0 || sites.isEmpty()) {
return ReceiverFacts.empty();
}
// Ambiguous: multiple writes that disagree → fail closed via ofAll size; also reject
// multiple writes even if they agree on type (safer path-scoped uniqueness).
if (writeCount != 1) {
return ReceiverFacts.empty();
}
return ReceiverFacts.ofAll(sites);
} finally {
visited.remove(visitKey);
}
}
/**
* Unique {@code receiver.setX(arg)} / {@code setX(arg)} on path whose arg has allocation facts.
*/
private ReceiverFacts factsFromUniqueSetterWrite(
Expression receiver,
String fieldName,
String getterName,
MethodDeclaration enclosingMethod,
List<String> methodPath,
Map<String, List<CallEdge>> callGraph,
int depth,
Set<String> visited) {
if (receiver == null || methodPath == null || fieldName == null || fieldName.isBlank()) {
return ReceiverFacts.empty();
}
String setterName = "set" + Character.toUpperCase(fieldName.charAt(0))
+ (fieldName.length() > 1 ? fieldName.substring(1) : "");
String receiverText = CallSiteArgBinder.normalizeExprText(receiver);
LinkedHashSet<AllocationSite> sites = new LinkedHashSet<>();
int matchCount = 0;
Set<Integer> seenBodies = new LinkedHashSet<>();
for (String hop : methodPath) {
MethodDeclaration md = findMethodDeclaration(hop);
if (md == null || md.getBody() == null) {
continue;
}
int bodyId = System.identityHashCode(md);
if (!seenBodies.add(bodyId)) {
continue;
}
List<Expression> setterArgs = collectSetterArgs(md, setterName, receiverText);
for (Expression arg : setterArgs) {
matchCount++;
ReceiverFacts nested = factsAtRaw(arg, md, methodPath, callGraph, depth + 1, visited);
sites.addAll(nested.sites());
}
}
// Same-method setter before getter (path may not re-list enclosing).
if (enclosingMethod != null && enclosingMethod.getBody() != null
&& seenBodies.add(System.identityHashCode(enclosingMethod))) {
List<Expression> local = collectSetterArgs(enclosingMethod, setterName, receiverText);
for (Expression arg : local) {
matchCount++;
ReceiverFacts nested = factsAtRaw(
arg, enclosingMethod, methodPath, callGraph, depth + 1, visited);
sites.addAll(nested.sites());
}
}
if (matchCount != 1 || sites.isEmpty()) {
return ReceiverFacts.empty();
}
return ReceiverFacts.ofAll(sites);
}
private static String instanceFieldName(Expression peeled) {
if (peeled instanceof FieldAccess fa
&& (fa.getExpression() instanceof ThisExpression || fa.getExpression() == null)) {
return fa.getName().getIdentifier();
}
if (peeled instanceof SimpleName sn) {
return sn.getIdentifier();
}
return null;
}
private static boolean ownerDeclaresField(TypeDeclaration owner, String fieldName) {
if (owner == null || fieldName == null) {
return false;
}
for (FieldDeclaration fd : owner.getFields()) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment frag
&& fieldName.equals(frag.getName().getIdentifier())) {
return true;
}
}
}
return false;
}
private boolean hopContainsType(String methodFqn, TypeDeclaration owner) {
if (methodFqn == null || owner == null) {
return false;
}
String typeFqn = context.getFqn(owner);
if (typeFqn != null && methodFqn.startsWith(typeFqn + ".")) {
return true;
}
String simple = owner.getName().getIdentifier();
return methodFqn.contains("." + simple + ".") || methodFqn.startsWith(simple + ".");
}
private static List<Expression> collectFieldWrites(MethodDeclaration method, String fieldName) {
List<Expression> writes = new java.util.ArrayList<>();
method.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(Assignment node) {
Expression lhs = node.getLeftHandSide();
if (lhs instanceof SimpleName sn && fieldName.equals(sn.getIdentifier())) {
writes.add(node.getRightHandSide());
} else if (lhs instanceof FieldAccess fa
&& fieldName.equals(fa.getName().getIdentifier())
&& (fa.getExpression() instanceof ThisExpression || fa.getExpression() == null)) {
writes.add(node.getRightHandSide());
}
return super.visit(node);
}
@Override
public boolean visit(MethodInvocation node) {
String name = node.getName().getIdentifier();
if (name != null && name.startsWith("set") && name.length() > 3
&& javaBeansFieldName(name).equals(fieldName)
&& node.arguments().size() == 1
&& (node.getExpression() == null || node.getExpression() instanceof ThisExpression)) {
writes.add((Expression) node.arguments().get(0));
}
return super.visit(node);
}
});
return writes;
}
private static List<Expression> collectSetterArgs(
MethodDeclaration method, String setterName, String receiverText) {
List<Expression> args = new java.util.ArrayList<>();
method.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (!setterName.equals(node.getName().getIdentifier()) || node.arguments().size() != 1) {
return super.visit(node);
}
Expression recv = node.getExpression();
if (recv == null) {
return super.visit(node);
}
if (receiverText != null && receiverText.equals(CallSiteArgBinder.normalizeExprText(recv))) {
args.add((Expression) node.arguments().get(0));
}
return super.visit(node);
}
});
return args;
}
private Expression resolveGetterToCtorArg(AllocationSite site, String getterName) { private Expression resolveGetterToCtorArg(AllocationSite site, String getterName) {
ClassInstanceCreation cic = site.cic(); ClassInstanceCreation cic = site.cic();
if (cic == null || getterName == null) { if (cic == null || getterName == null) {
@@ -435,7 +720,7 @@ public final class AllocationFactPropagator {
if (edge == null || edge.getTargetMethod() == null) { if (edge == null || edge.getTargetMethod() == null) {
continue; continue;
} }
if (!methodsMatch(edge.getTargetMethod(), calleeMethodFqn)) { if (!CallSiteArgBinder.methodsMatch(edge.getTargetMethod(), calleeMethodFqn)) {
continue; continue;
} }
List<String> args = edge.getArguments(); List<String> args = edge.getArguments();
@@ -443,8 +728,14 @@ public final class AllocationFactPropagator {
continue; continue;
} }
// Prefer AST so nested CIC args stay attached for getter peels. // Prefer AST so nested CIC args stay attached for getter peels.
ReceiverFacts nested = factsFromCallerAstArgument( MethodDeclaration callerMd = findMethodDeclaration(callerMethodFqn);
callerMethodFqn, edge.getTargetMethod(), paramIndex, methodPath, callGraph, depth, visited); ReceiverFacts nested = ReceiverFacts.empty();
if (callerMd != null) {
var bound = CallSiteArgBinder.findUniqueArgument(callerMd, edge, paramIndex);
if (bound.isPresent()) {
nested = factsAtRaw(bound.get(), callerMd, methodPath, callGraph, depth + 1, visited);
}
}
if (!nested.isEmpty()) { if (!nested.isEmpty()) {
sites.addAll(nested.sites()); sites.addAll(nested.sites());
continue; continue;
@@ -457,38 +748,6 @@ public final class AllocationFactPropagator {
return ReceiverFacts.ofAll(sites); return ReceiverFacts.ofAll(sites);
} }
private ReceiverFacts factsFromCallerAstArgument(
String callerMethodFqn,
String targetMethodFqn,
int paramIndex,
List<String> methodPath,
Map<String, List<CallEdge>> callGraph,
int depth,
Set<String> visited) {
MethodDeclaration callerMd = findMethodDeclaration(callerMethodFqn);
if (callerMd == null || callerMd.getBody() == null) {
return ReceiverFacts.empty();
}
String targetSimple = methodNameOf(targetMethodFqn);
LinkedHashSet<AllocationSite> sites = new LinkedHashSet<>();
callerMd.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (!targetSimple.equals(node.getName().getIdentifier())) {
return super.visit(node);
}
if (paramIndex >= node.arguments().size()) {
return super.visit(node);
}
Expression arg = (Expression) node.arguments().get(paramIndex);
ReceiverFacts nested = factsAt(arg, callerMd, methodPath, callGraph, depth + 1, visited);
sites.addAll(nested.sites());
return super.visit(node);
}
});
return ReceiverFacts.ofAll(sites);
}
private AllocationSite siteFromCic(ClassInstanceCreation cic) { private AllocationSite siteFromCic(ClassInstanceCreation cic) {
if (cic == null) { if (cic == null) {
return null; return null;
@@ -649,29 +908,18 @@ public final class AllocationFactPropagator {
} }
private static String methodNameOf(String methodFqn) { private static String methodNameOf(String methodFqn) {
if (methodFqn == null) { return CallSiteArgBinder.methodNameOf(methodFqn);
return null;
}
int lastDot = methodFqn.lastIndexOf('.');
return lastDot >= 0 ? methodFqn.substring(lastDot + 1) : methodFqn;
} }
private static boolean methodsMatch(String left, String right) { private static boolean methodsMatch(String left, String right) {
if (left == null || right == null) { return CallSiteArgBinder.methodsMatch(left, right);
return false;
}
if (left.equals(right)) {
return true;
}
return methodNameOf(left).equals(methodNameOf(right))
&& (left.endsWith("." + methodNameOf(right)) || right.endsWith("." + methodNameOf(left)));
} }
private static int indexOfMethodOnPath(List<String> methodPath, String methodFqn) { private static int indexOfMethodOnPath(List<String> methodPath, String methodFqn) {
if (methodPath == null || methodFqn == null) { if (methodPath == null || methodFqn == null) {
return -1; return -1;
} }
String simple = methodNameOf(methodFqn); String simple = CallSiteArgBinder.methodNameOf(methodFqn);
for (int i = methodPath.size() - 1; i >= 0; i--) { for (int i = methodPath.size() - 1; i >= 0; i--) {
String hop = methodPath.get(i); String hop = methodPath.get(i);
if (hop != null && (hop.equals(methodFqn) || hop.endsWith("." + simple))) { if (hop != null && (hop.equals(methodFqn) || hop.endsWith("." + simple))) {

View File

@@ -0,0 +1,265 @@
package click.kamil.springstatemachineexporter.analysis.flow.alloc;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.CastExpression;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.IfStatement;
import org.eclipse.jdt.core.dom.InstanceofExpression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.Modifier;
import org.eclipse.jdt.core.dom.ParenthesizedExpression;
import org.eclipse.jdt.core.dom.PatternInstanceofExpression;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
import org.eclipse.jdt.core.dom.Statement;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.TypePattern;
/**
* Dominating {@code instanceof} / pattern narrowing → unique concrete {@link AllocationSite}.
* Shared by {@link AllocationFactPropagator}; fail-closed for else-branch, abstract targets,
* and sends outside the narrow.
*/
public final class InstanceofNarrower {
private InstanceofNarrower() {
}
/**
* Facts when {@code useSite} is dominated by {@code recv instanceof T} (then-branch)
* or {@code recv} is pattern variable {@code T p}.
*/
public static ReceiverFacts factsAt(
Expression receiver,
ASTNode useSite,
CodebaseContext context) {
if (context == null || receiver == null || useSite == null) {
return ReceiverFacts.empty();
}
Expression peeledRecv = peel(receiver);
if (!(peeledRecv instanceof SimpleName recvSn)) {
return ReceiverFacts.empty();
}
String recvName = recvSn.getIdentifier();
MethodDeclaration enclosing = findEnclosingMethod(useSite);
String patternType = resolvePatternVariableType(recvName, enclosing);
if (patternType != null) {
return factsFromConcreteTypeName(patternType, context);
}
ASTNode current = useSite;
while (current != null && !(current instanceof MethodDeclaration)) {
ASTNode parent = current.getParent();
if (parent instanceof IfStatement ifStmt) {
if (isNodeUnder(ifStmt.getThenStatement(), current)) {
ReceiverFacts fromCond = extractInstanceofFacts(
ifStmt.getExpression(), recvName, context);
if (!fromCond.isEmpty()) {
return fromCond;
}
}
}
current = parent;
}
return ReceiverFacts.empty();
}
/**
* Merge base AFP facts with dominating instanceof.
* <ul>
* <li>instanceof empty → base unchanged</li>
* <li>base empty → instanceof</li>
* <li>base unique is Object / missing TD / interface / abstract → refine to instanceof</li>
* <li>base unique concrete equals instanceof → that site</li>
* <li>base unique concrete conflicts with instanceof → empty (fail-closed)</li>
* </ul>
*/
public static ReceiverFacts refine(
ReceiverFacts base,
Expression receiver,
ASTNode useSite,
CodebaseContext context) {
ReceiverFacts narrowed = factsAt(receiver, useSite, context);
if (narrowed.isEmpty()) {
return base == null ? ReceiverFacts.empty() : base;
}
if (base == null || base.isEmpty()) {
return narrowed;
}
AllocationSite baseUnique = base.uniqueOrNull();
AllocationSite narrowUnique = narrowed.uniqueOrNull();
if (baseUnique == null || narrowUnique == null) {
return ReceiverFacts.empty();
}
if (isWeakAllocSite(baseUnique, context)) {
return narrowed;
}
if (sameType(baseUnique.typeFqn(), narrowUnique.typeFqn())) {
return ReceiverFacts.of(baseUnique.preferRich(narrowUnique));
}
return ReceiverFacts.empty();
}
public static String resolvePatternVariableType(String varName, MethodDeclaration enclosing) {
if (varName == null || enclosing == null || enclosing.getBody() == null) {
return null;
}
String[] found = {null};
enclosing.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(PatternInstanceofExpression node) {
SingleVariableDeclaration svd = patternVariableOf(node);
if (svd != null
&& varName.equals(svd.getName().getIdentifier())
&& svd.getType() != null) {
found[0] = svd.getType().toString();
return false;
}
return super.visit(node);
}
});
if (found[0] == null) {
return null;
}
String type = found[0];
int generic = type.indexOf('<');
return generic >= 0 ? type.substring(0, generic) : type;
}
private static boolean isWeakAllocSite(AllocationSite site, CodebaseContext context) {
if (site == null || site.typeFqn() == null || site.typeFqn().isBlank()) {
return true;
}
String fqn = site.typeFqn();
if ("java.lang.Object".equals(fqn) || "Object".equals(fqn)) {
return true;
}
TypeDeclaration td = resolveTypeDeclaration(fqn, context);
if (td == null) {
return true;
}
return td.isInterface() || Modifier.isAbstract(td.getModifiers());
}
private static boolean sameType(String a, String b) {
if (a == null || b == null) {
return false;
}
if (a.equals(b)) {
return true;
}
String simpleA = a.contains(".") ? a.substring(a.lastIndexOf('.') + 1) : a;
String simpleB = b.contains(".") ? b.substring(b.lastIndexOf('.') + 1) : b;
return simpleA.equals(simpleB);
}
private static ReceiverFacts extractInstanceofFacts(
Expression condition,
String recvName,
CodebaseContext context) {
Expression cond = peel(condition);
if (cond instanceof InstanceofExpression io) {
Expression left = peel(io.getLeftOperand());
if (left instanceof SimpleName sn && recvName.equals(sn.getIdentifier())
&& io.getRightOperand() != null) {
return factsFromConcreteTypeName(io.getRightOperand().toString(), context);
}
}
if (cond instanceof PatternInstanceofExpression pio) {
Expression left = peel(pio.getLeftOperand());
if (left instanceof SimpleName sn && recvName.equals(sn.getIdentifier())) {
SingleVariableDeclaration svd = patternVariableOf(pio);
if (svd != null && svd.getType() != null) {
return factsFromConcreteTypeName(svd.getType().toString(), context);
}
}
}
return ReceiverFacts.empty();
}
private static ReceiverFacts factsFromConcreteTypeName(String typeFqn, CodebaseContext context) {
if (typeFqn == null || typeFqn.isBlank()) {
return ReceiverFacts.empty();
}
int generic = typeFqn.indexOf('<');
if (generic >= 0) {
typeFqn = typeFqn.substring(0, generic);
}
TypeDeclaration td = resolveTypeDeclaration(typeFqn, context);
if (td == null || td.isInterface() || Modifier.isAbstract(td.getModifiers())) {
return ReceiverFacts.empty();
}
String fqn = context.getFqn(td);
return ReceiverFacts.of(new AllocationSite(fqn != null ? fqn : typeFqn));
}
private static TypeDeclaration resolveTypeDeclaration(String typeFqn, CodebaseContext context) {
TypeDeclaration td = context.getTypeDeclaration(typeFqn);
if (td == null) {
String simple = typeFqn.contains(".") ? typeFqn.substring(typeFqn.lastIndexOf('.') + 1) : typeFqn;
td = context.getTypeDeclaration(simple);
}
return td;
}
private static SingleVariableDeclaration patternVariableOf(PatternInstanceofExpression node) {
if (node == null) {
return null;
}
try {
if (node.getPattern() instanceof TypePattern tp) {
SingleVariableDeclaration pv = tp.getPatternVariable();
if (pv != null) {
return pv;
}
}
} catch (RuntimeException ignored) {
// API level may expose rightOperand instead of pattern
}
try {
return node.getRightOperand();
} catch (RuntimeException ignored) {
return null;
}
}
private static boolean isNodeUnder(Statement root, ASTNode node) {
if (root == null || node == null) {
return false;
}
ASTNode current = node;
while (current != null) {
if (current == root) {
return true;
}
if (current instanceof MethodDeclaration) {
return false;
}
current = current.getParent();
}
return false;
}
private static MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode current = node;
while (current != null) {
if (current instanceof MethodDeclaration md) {
return md;
}
current = current.getParent();
}
return null;
}
private static Expression peel(Expression expression) {
Expression current = expression;
while (current instanceof ParenthesizedExpression pe) {
current = pe.getExpression();
}
if (current instanceof CastExpression ce) {
return peel(ce.getExpression());
}
return current;
}
}

View File

@@ -146,7 +146,7 @@ public final class VirtualAccessorConstResolver {
/** /**
* Drop symbolic / enum-set / blank; keep FQN or bare constant identifiers. * Drop symbolic / enum-set / blank; keep FQN or bare constant identifiers.
*/ */
static String normalizeConstant(String value) { public static String normalizeConstant(String value) {
if (value == null || value.isBlank()) { if (value == null || value.isBlank()) {
return null; return null;
} }

View File

@@ -0,0 +1,49 @@
package click.kamil.springstatemachineexporter.analysis.flow.eventid;
import click.kamil.springstatemachineexporter.analysis.flow.alloc.ReceiverFacts;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
/**
* Proven virtual accessor on a receiver with allocation (or concrete-type) facts.
*/
public final class AccessProof {
private final ReceiverFacts receiverFacts;
private final String accessorName;
private final MethodInvocation accessorCall;
private final MethodDeclaration enclosingMethod;
public AccessProof(
ReceiverFacts receiverFacts,
String accessorName,
MethodInvocation accessorCall,
MethodDeclaration enclosingMethod) {
this.receiverFacts = receiverFacts == null ? ReceiverFacts.empty() : receiverFacts;
this.accessorName = accessorName;
this.accessorCall = accessorCall;
this.enclosingMethod = enclosingMethod;
}
public ReceiverFacts receiverFacts() {
return receiverFacts;
}
public String accessorName() {
return accessorName;
}
public MethodInvocation accessorCall() {
return accessorCall;
}
public MethodDeclaration enclosingMethod() {
return enclosingMethod;
}
public boolean isValid() {
return accessorName != null
&& !accessorName.isBlank()
&& !receiverFacts.isEmpty();
}
}

View File

@@ -9,7 +9,7 @@ import java.util.Set;
public final class LibraryUnwrapRegistry { public final class LibraryUnwrapRegistry {
private static final Set<String> UNWRAP_RECEIVER_TYPES = Set.of( private static final Set<String> UNWRAP_RECEIVER_TYPES = Set.of(
"Optional", "Stream", "List", "Set", "Arrays", "Objects", "Mono", "Flux"); "Optional", "Stream", "List", "Set", "Arrays", "Objects", "Mono", "Flux", "MessageBuilder");
private static final Set<String> UNWRAP_METHOD_NAMES = Set.of( private static final Set<String> UNWRAP_METHOD_NAMES = Set.of(
"of", "ofEntries", "asList", "entry", "just", "withPayload", "success"); "of", "ofEntries", "asList", "entry", "just", "withPayload", "success");
@@ -18,6 +18,9 @@ public final class LibraryUnwrapRegistry {
private static final Set<String> REACTIVE_TRANSFORM_METHODS = Set.of("map", "flatMap", "switchMap", "concatMap"); private static final Set<String> REACTIVE_TRANSFORM_METHODS = Set.of("map", "flatMap", "switchMap", "concatMap");
/** Fixed header keys that may carry a machine event (free-form / dynamic keys stay fail-closed). */
private static final Set<String> EVENT_HEADER_KEYS = Set.of("event", "smEvent");
private static final String[] STOP_UNWRAP_PREFIXES = { private static final String[] STOP_UNWRAP_PREFIXES = {
"map", "parse", "convert", "to", "resolve", "build", "create", "from", "map", "parse", "convert", "to", "resolve", "build", "create", "from",
"transform", "translate", "derive", "determine", "calculate", "decode", "transform", "translate", "derive", "determine", "calculate", "decode",
@@ -68,4 +71,20 @@ public final class LibraryUnwrapRegistry {
public static boolean isHeaderSetterMethodName(String methodName) { public static boolean isHeaderSetterMethodName(String methodName) {
return "setHeader".equals(methodName); return "setHeader".equals(methodName);
} }
public static boolean isEventHeaderKey(String headerKey) {
return headerKey != null && EVENT_HEADER_KEYS.contains(headerKey);
}
public static boolean isGenericMessageType(String typeName) {
if (typeName == null || typeName.isBlank()) {
return false;
}
String simple = typeName.contains(".") ? typeName.substring(typeName.lastIndexOf('.') + 1) : typeName;
int generic = simple.indexOf('<');
if (generic >= 0) {
simple = simple.substring(0, generic);
}
return "GenericMessage".equals(simple);
}
} }

View File

@@ -0,0 +1,102 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import org.eclipse.jdt.core.dom.CastExpression;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.ParenthesizedExpression;
import org.eclipse.jdt.core.dom.StringLiteral;
/**
* Shared MessageBuilder / GenericMessage / allowlisted-header peels for event identity.
* Used by {@code EventIdentityResolver} and call-graph unwrap paths.
*/
public final class MessageCarrierSupport {
private MessageCarrierSupport() {
}
/**
* Prefer allowlisted {@code setHeader("event"|"smEvent", …)} on a MessageBuilder chain;
* else peel {@code withPayload}/{@code just}/{@code success}; else GenericMessage ctor arg.
*
* @return the event-bearing expression, or null when nothing is proven
*/
public static Expression peelEventFromMessageExpr(Expression expression) {
if (expression == null) {
return null;
}
Expression peeled = peel(expression);
Expression headerEvent = findSetHeaderValueOnChain(peeled, null);
if (headerEvent != null) {
return peel(headerEvent);
}
Expression payload = ReactiveExpressionSupport.peelFactoryPayloadExpression(peeled);
if (payload != null) {
return peel(payload);
}
return peelGenericMessageCtor(peeled);
}
public static Expression peelGenericMessageCtor(Expression expression) {
Expression peeled = peel(expression);
if (peeled instanceof ClassInstanceCreation cic
&& LibraryUnwrapRegistry.isGenericMessageType(cic.getType().toString())
&& !cic.arguments().isEmpty()
&& cic.arguments().get(0) instanceof Expression arg) {
return peel(arg);
}
return null;
}
/**
* Walk a MessageBuilder chain for {@code setHeader(key, value)}. When {@code requiredKey}
* is null, returns the first allowlisted event-header value on the chain.
*/
public static Expression findSetHeaderValueOnChain(Expression expression, String requiredKey) {
Expression current = peel(expression);
while (current instanceof MethodInvocation mi) {
if (LibraryUnwrapRegistry.isHeaderSetterMethodName(mi.getName().getIdentifier())
&& mi.arguments().size() >= 2) {
String key = stringLiteralValue(mi.arguments().get(0));
if (requiredKey != null) {
if (requiredKey.equals(key) && mi.arguments().get(1) instanceof Expression value) {
return value;
}
} else if (LibraryUnwrapRegistry.isEventHeaderKey(key)
&& mi.arguments().get(1) instanceof Expression value) {
return value;
}
}
if ("build".equals(mi.getName().getIdentifier()) && mi.arguments().isEmpty()
&& mi.getExpression() != null) {
current = peel(mi.getExpression());
continue;
}
if (mi.getExpression() instanceof MethodInvocation next) {
current = next;
} else {
break;
}
}
return null;
}
private static String stringLiteralValue(Object arg) {
if (arg instanceof StringLiteral sl) {
return sl.getLiteralValue();
}
return null;
}
private static Expression peel(Expression expression) {
Expression current = expression;
while (current instanceof ParenthesizedExpression pe) {
current = pe.getExpression();
}
if (current instanceof CastExpression ce) {
return peel(ce.getExpression());
}
return current;
}
}

View File

@@ -2,8 +2,8 @@ package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.flow.alloc.AllocationFactPropagator; import click.kamil.springstatemachineexporter.analysis.flow.alloc.AllocationFactPropagator;
import click.kamil.springstatemachineexporter.analysis.flow.alloc.ReceiverFacts;
import click.kamil.springstatemachineexporter.analysis.flow.alloc.VirtualAccessorConstResolver; import click.kamil.springstatemachineexporter.analysis.flow.alloc.VirtualAccessorConstResolver;
import click.kamil.springstatemachineexporter.analysis.flow.eventid.EventIdentityResolver;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver; import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder; import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
@@ -39,6 +39,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
protected final PathBindingEvaluator pathBindingEvaluator; protected final PathBindingEvaluator pathBindingEvaluator;
protected final AllocationFactPropagator allocationFactPropagator; protected final AllocationFactPropagator allocationFactPropagator;
protected final VirtualAccessorConstResolver virtualAccessorConstResolver; protected final VirtualAccessorConstResolver virtualAccessorConstResolver;
protected final EventIdentityResolver eventIdentityResolver;
private final Map<String, ASTNode> parsedNodeCache = new HashMap<>(); private final Map<String, ASTNode> parsedNodeCache = new HashMap<>();
private final Map<String, List<String>> polymorphicCallCache = new HashMap<>(); private final Map<String, List<String>> polymorphicCallCache = new HashMap<>();
private final Map<String, Map<String, String>> pathBindingsCache = new HashMap<>(); private final Map<String, Map<String, String>> pathBindingsCache = new HashMap<>();
@@ -84,6 +85,12 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
context, variableTracer, constructorAnalyzer); context, variableTracer, constructorAnalyzer);
this.virtualAccessorConstResolver = new VirtualAccessorConstResolver( this.virtualAccessorConstResolver = new VirtualAccessorConstResolver(
context, accessorResolver, constantResolver); context, accessorResolver, constantResolver);
this.eventIdentityResolver = new EventIdentityResolver(
context,
allocationFactPropagator,
virtualAccessorConstResolver,
variableTracer,
constantResolver);
} }
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) { public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
@@ -96,6 +103,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
pathBindingEvaluator.clearAnalysisCaches(); pathBindingEvaluator.clearAnalysisCaches();
pathBindingsCache.clear(); pathBindingsCache.clear();
parsedNodeCache.clear(); parsedNodeCache.clear();
if (eventIdentityResolver != null) {
eventIdentityResolver.clearSessionCache();
}
Map<String, List<CallEdge>> callGraph = buildCallGraph(); Map<String, List<CallEdge>> callGraph = buildCallGraph();
List<CallChain> chains = new ArrayList<>(); List<CallChain> chains = new ArrayList<>();
@@ -1332,8 +1342,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, polymorphicEvents); return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, polymorphicEvents);
} }
// valueOf(e.getType()) may have been collapsed to <SYMBOLIC:Enum.*> on the sendEvent // valueOf(e.getType()) may have been collapsed to <SYMBOLIC:Enum.*> on the sendEvent
// edge — recover via allocation-sensitive getType on the dispatcher frame. // edge — recover via event-identity resolution on the dispatcher frame.
List<String> recovered = recoverAllocationSensitiveAccessorFromPath(path, callGraph); List<String> recovered = eventIdentityResolver != null
? eventIdentityResolver.recoverFromPath(path, callGraph != null ? callGraph : this.graph)
: List.of();
if (recovered.size() == 1) { if (recovered.size() == 1) {
return buildTriggerPointWithPolymorphicEvents( return buildTriggerPointWithPolymorphicEvents(
tp, tp,
@@ -1688,9 +1700,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
} }
/** /**
* When a virtual accessor ({@code getType}/{@code getEvent}/…) has a receiver proven to be a * When a virtual accessor ({@code getType}/{@code getA}/…) has a receiver proven to be a
* concrete {@code new T()}, resolve only that override's compile-time constant instead of * concrete {@code new T()} (or concrete declared type), resolve only that override's
* unioning all interface implementors. * compile-time constant instead of unioning all interface implementors.
* *
* @return true when a unique constant was proven and written into {@code polymorphicEvents} * @return true when a unique constant was proven and written into {@code polymorphicEvents}
*/ */
@@ -1700,20 +1712,20 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
List<String> path, List<String> path,
Map<String, List<CallEdge>> callGraph, Map<String, List<CallEdge>> callGraph,
List<String> polymorphicEvents) { List<String> polymorphicEvents) {
if (!(exprNode instanceof Expression expression) if (!(exprNode instanceof Expression expression) || eventIdentityResolver == null) {
|| allocationFactPropagator == null
|| virtualAccessorConstResolver == null) {
return false; return false;
} }
MethodInvocation accessorCall = findVirtualAccessorInvocation(expression, methodName);
if (accessorCall == null) {
return false;
}
String accessorName = accessorCall.getName().getIdentifier();
Map<String, List<CallEdge>> graphForFacts = callGraph != null ? callGraph : this.graph; Map<String, List<CallEdge>> graphForFacts = callGraph != null ? callGraph : this.graph;
ReceiverFacts facts = allocationFactPropagator.factsForAccessorReceiver( List<String> unique = eventIdentityResolver.resolveUnique(expression, path, graphForFacts);
accessorCall, path, graphForFacts); if (unique.size() != 1) {
List<String> unique = virtualAccessorConstResolver.resolveUniqueConstant(facts, accessorName); // Prefer peeling a nested accessor when methodName hints at get*
if (methodName != null && EventIdentityResolver.isVirtualAccessorName(methodName)) {
MethodInvocation accessorCall = findVirtualAccessorInvocation(expression, methodName);
if (accessorCall != null && accessorCall != expression) {
unique = eventIdentityResolver.resolveUnique(accessorCall, path, graphForFacts);
}
}
}
if (unique.size() != 1) { if (unique.size() != 1) {
return false; return false;
} }
@@ -1733,7 +1745,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
} }
if (peeled instanceof MethodInvocation mi) { if (peeled instanceof MethodInvocation mi) {
String name = mi.getName().getIdentifier(); String name = mi.getName().getIdentifier();
if (preferredName != null && preferredName.equals(name) && isVirtualAccessorName(name)) { if (preferredName != null && preferredName.equals(name)
&& EventIdentityResolver.isVirtualAccessorName(name)) {
return mi; return mi;
} }
if ("valueOf".equals(name) && mi.arguments().size() == 1 if ("valueOf".equals(name) && mi.arguments().size() == 1
@@ -1743,10 +1756,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return nested; return nested;
} }
} }
if (preferredName == null && isVirtualAccessorName(name)) { if (preferredName == null && EventIdentityResolver.isVirtualAccessorName(name)) {
return mi; return mi;
} }
if (isVirtualAccessorName(name)) { if (EventIdentityResolver.isVirtualAccessorName(name)) {
return mi; return mi;
} }
} }
@@ -1754,21 +1767,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
} }
private static boolean isVirtualAccessorName(String name) { private static boolean isVirtualAccessorName(String name) {
if (name == null || name.isBlank()) { return EventIdentityResolver.isVirtualAccessorName(name);
return false;
}
return isEventIdentityAccessorName(name)
|| (name.startsWith("get") && name.length() > 3);
} }
/**
* Accessors that identify the machine event (not arbitrary getters on the same frame).
*/
private static boolean isEventIdentityAccessorName(String name) { private static boolean isEventIdentityAccessorName(String name) {
return "getType".equals(name) return EventIdentityResolver.isEventIdentityAccessorName(name);
|| "getEvent".equals(name)
|| "getId".equals(name)
|| "resolveEventCode".equals(name);
} }
/** /**
@@ -1798,7 +1801,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
List<String> path, List<String> path,
Map<String, List<CallEdge>> callGraph, Map<String, List<CallEdge>> callGraph,
List<String> polymorphicEvents) { List<String> polymorphicEvents) {
List<String> recovered = recoverAllocationSensitiveAccessorFromPath(path, callGraph); if (eventIdentityResolver == null) {
return false;
}
Map<String, List<CallEdge>> graphForFacts = callGraph != null ? callGraph : this.graph;
List<String> recovered = eventIdentityResolver.recoverFromPath(path, graphForFacts);
if (recovered.size() != 1) { if (recovered.size() != 1) {
return false; return false;
} }
@@ -1807,91 +1814,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return true; return true;
} }
/**
* When sendEvent's call-graph argument was collapsed to {@code <SYMBOLIC:Enum.*>}, walk the
* dispatcher frame for {@code valueOf(receiver.getType())} / event-identity accessors and
* apply allocation-sensitive constant resolution using the call path.
*/
private List<String> recoverAllocationSensitiveAccessorFromPath(
List<String> path,
Map<String, List<CallEdge>> callGraph) {
if (path == null || path.size() < 2
|| allocationFactPropagator == null
|| virtualAccessorConstResolver == null) {
return List.of();
}
// Prefer the frame that calls sendEvent (usually path.size()-2).
for (int i = path.size() - 2; i >= 0; i--) {
String methodFqn = path.get(i);
if (methodFqn == null || !methodFqn.contains(".")) {
continue;
}
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) {
continue;
}
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null || md.getBody() == null) {
continue;
}
List<MethodInvocation> preferred = new ArrayList<>();
List<MethodInvocation> fallback = new ArrayList<>();
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
String name = node.getName().getIdentifier();
// Highest priority: nested in Enum.valueOf(...)
if ("valueOf".equals(name) && node.arguments().size() == 1
&& node.arguments().get(0) instanceof MethodInvocation nested
&& isVirtualAccessorName(nested.getName().getIdentifier())) {
preferred.add(nested);
return super.visit(node);
}
if (node.getExpression() == null || !isVirtualAccessorName(name)) {
return super.visit(node);
}
if (isEventIdentityAccessorName(name)) {
preferred.add(node);
} else {
fallback.add(node);
}
return super.visit(node);
}
});
Map<String, List<CallEdge>> graphForFacts = callGraph != null ? callGraph : this.graph;
List<String> fromPreferred = resolveUniqueFromAccessors(preferred, path, graphForFacts);
if (fromPreferred.size() == 1) {
return fromPreferred;
}
List<String> fromFallback = resolveUniqueFromAccessors(fallback, path, graphForFacts);
if (fromFallback.size() == 1) {
return fromFallback;
}
}
return List.of();
}
private List<String> resolveUniqueFromAccessors(
List<MethodInvocation> accessors,
List<String> path,
Map<String, List<CallEdge>> graphForFacts) {
for (MethodInvocation accessor : accessors) {
if (accessor == null) {
continue;
}
ReceiverFacts facts = allocationFactPropagator.factsForAccessorReceiver(
accessor, path, graphForFacts);
List<String> unique = virtualAccessorConstResolver.resolveUniqueConstant(
facts, accessor.getName().getIdentifier());
if (unique.size() == 1) {
return unique;
}
}
return List.of();
}
private String tryNarrowGetterViaLocalVarChain(MethodInvocation getterCall, SimpleName instanceSn) { private String tryNarrowGetterViaLocalVarChain(MethodInvocation getterCall, SimpleName instanceSn) {
MethodDeclaration enclosing = findEnclosingMethod(getterCall); MethodDeclaration enclosing = findEnclosingMethod(getterCall);
if (enclosing == null || enclosing.getBody() == null) { if (enclosing == null || enclosing.getBody() == null) {
@@ -2296,6 +2218,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
resolvedValue, methodSuffix); resolvedValue, methodSuffix);
List<String> searchMethods = buildGetterSearchMethods(resolvedValue, entryMethod, path); List<String> searchMethods = buildGetterSearchMethods(resolvedValue, entryMethod, path);
// Prefer allocation-/identity-sensitive proof before CHA accessor union.
List<String> viaEir = resolveGetterConstantsViaEventIdentity(
expressionText, searchMethods, path, callGraph);
if (viaEir.size() == 1) {
return viaEir;
}
List<String> constants = resolveGetterConstantsViaDataflow(expressionText, searchMethods, path, callGraph, entryMethod); List<String> constants = resolveGetterConstantsViaDataflow(expressionText, searchMethods, path, callGraph, entryMethod);
if (!constants.isEmpty()) { if (!constants.isEmpty()) {
return constants; return constants;
@@ -2305,6 +2234,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
String remapped = ReactiveExpressionSupport.remapLambdaParameterGetterInMethod( String remapped = ReactiveExpressionSupport.remapLambdaParameterGetterInMethod(
expressionText, scopeMethod, constantResolver, context); expressionText, scopeMethod, constantResolver, context);
if (remapped != null && !remapped.equals(expressionText)) { if (remapped != null && !remapped.equals(expressionText)) {
viaEir = resolveGetterConstantsViaEventIdentity(remapped, searchMethods, path, callGraph);
if (viaEir.size() == 1) {
return viaEir;
}
constants = resolveGetterConstantsViaDataflow(remapped, searchMethods, path, callGraph, entryMethod); constants = resolveGetterConstantsViaDataflow(remapped, searchMethods, path, callGraph, entryMethod);
if (!constants.isEmpty()) { if (!constants.isEmpty()) {
return constants; return constants;
@@ -2314,6 +2247,23 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return resolveGetterConstantsViaAccessorPipeline(resolvedValue, methodSuffix, entryMethod, path); return resolveGetterConstantsViaAccessorPipeline(resolvedValue, methodSuffix, entryMethod, path);
} }
private List<String> resolveGetterConstantsViaEventIdentity(
String expressionText,
List<String> searchMethods,
List<String> path,
Map<String, List<CallEdge>> callGraph) {
if (eventIdentityResolver == null || expressionText == null || expressionText.isBlank()) {
return List.of();
}
Expression anchored = click.kamil.springstatemachineexporter.ast.common.AstUtils.findExpressionInMethods(
searchMethods, expressionText, context);
if (anchored == null) {
return List.of();
}
Map<String, List<CallEdge>> graphForFacts = callGraph != null ? callGraph : this.graph;
return eventIdentityResolver.resolveUnique(anchored, path, graphForFacts);
}
private List<String> resolveGetterConstantsViaDataflow( private List<String> resolveGetterConstantsViaDataflow(
String expressionText, String expressionText,
List<String> searchMethods, List<String> searchMethods,

View File

@@ -1,5 +1,6 @@
package click.kamil.springstatemachineexporter.analysis.service; package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.pipeline.MessageCarrierSupport;
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport; import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer; import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
@@ -13,6 +14,9 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
public JdtCallGraphEngine(CodebaseContext context, InjectionPointAnalyzer injectionAnalyzer) { public JdtCallGraphEngine(CodebaseContext context, InjectionPointAnalyzer injectionAnalyzer) {
super(context); super(context);
this.injectionAnalyzer = injectionAnalyzer; this.injectionAnalyzer = injectionAnalyzer;
if (allocationFactPropagator != null) {
allocationFactPropagator.setInjectionAnalyzer(injectionAnalyzer);
}
} }
@Override @Override
@@ -175,7 +179,11 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
} }
private Expression unwrapMessageBuilder(Expression expr) { private Expression unwrapMessageBuilder(Expression expr) {
Expression peeled = ReactiveExpressionSupport.peelFactoryPayloadExpression(expr); Expression peeled = MessageCarrierSupport.peelEventFromMessageExpr(expr);
return peeled != null ? peeled : expr; if (peeled != null) {
return peeled;
}
Expression factory = ReactiveExpressionSupport.peelFactoryPayloadExpression(expr);
return factory != null ? factory : expr;
} }
} }

View File

@@ -0,0 +1,99 @@
package click.kamil.springstatemachineexporter.analysis.flow;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
class CallSiteArgBinderTest {
@Test
void uniqueSiteWithoutEdgeText(@TempDir Path tempDir) throws Exception {
MethodDeclaration caller = scanCaller(tempDir, """
package com.example;
class C {
void fire(String a) { route(a); }
void route(String code) {}
}
""");
CallEdge edge = new CallEdge("com.example.C.route", List.of("a"));
Optional<?> arg = CallSiteArgBinder.findUniqueArgument(caller, edge, 0);
assertThat(arg).isPresent();
assertThat(arg.get().toString()).isEqualTo("a");
}
@Test
void multiSiteUsesEdgeText(@TempDir Path tempDir) throws Exception {
MethodDeclaration caller = scanCaller(tempDir, """
package com.example;
class C {
void fire(String a, String b) {
route(a);
route(b);
}
void route(String code) {}
}
""");
CallEdge edge = new CallEdge("com.example.C.route", List.of("b"));
Optional<?> arg = CallSiteArgBinder.findUniqueArgument(caller, edge, 0);
assertThat(arg).isPresent();
assertThat(arg.get().toString()).isEqualTo("b");
}
@Test
void multiSiteIdenticalTextPicksFirst(@TempDir Path tempDir) throws Exception {
MethodDeclaration caller = scanCaller(tempDir, """
package com.example;
class C {
void fire(String a) {
route(a);
route(a);
}
void route(String code) {}
}
""");
CallEdge edge = new CallEdge("com.example.C.route", List.of("a"));
Optional<?> arg = CallSiteArgBinder.findUniqueArgument(caller, edge, 0);
assertThat(arg).isPresent();
assertThat(arg.get().toString()).isEqualTo("a");
}
@Test
void multiSiteWithoutEdgeTextFailsClosed(@TempDir Path tempDir) throws Exception {
MethodDeclaration caller = scanCaller(tempDir, """
package com.example;
class C {
void fire(String a, String b) {
route(a);
route(b);
}
void route(String code) {}
}
""");
CallEdge edge = new CallEdge("com.example.C.route", null);
assertThat(CallSiteArgBinder.findUniqueArgument(caller, edge, 0)).isEmpty();
}
private static MethodDeclaration scanCaller(Path tempDir, String source) throws Exception {
Files.writeString(tempDir.resolve("C.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration td = context.getTypeDeclaration("com.example.C");
if (td == null) {
td = context.getTypeDeclaration("C");
}
assertThat(td).isNotNull();
MethodDeclaration fire = context.findMethodDeclaration(td, "fire", true);
assertThat(fire).isNotNull();
return fire;
}
}

View File

@@ -0,0 +1,152 @@
package click.kamil.springstatemachineexporter.analysis.flow.alloc;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class InstanceofNarrowerTest {
@Test
void patternVariableProvesConcrete(@TempDir Path tempDir) throws Exception {
Fixture f = scan(tempDir, """
package com.example;
class C {
void fire(Object o) {
if (o instanceof PayRichEvent p) {
use(p.getA());
}
}
void use(String s) {}
}
class PayRichEvent { String getA() { return "PAY"; } }
class ShipRichEvent { String getA() { return "SHIP"; } }
""");
MethodInvocation getA = findInvocation(f.fire, "getA");
ReceiverFacts facts = InstanceofNarrower.factsAt(getA.getExpression(), getA, f.context);
assertThat(facts.uniqueOrNull()).isNotNull();
assertThat(facts.uniqueOrNull().typeFqn()).contains("PayRichEvent");
}
@Test
void thenBranchClassicInstanceofProvesConcrete(@TempDir Path tempDir) throws Exception {
Fixture f = scan(tempDir, """
package com.example;
class C {
void fire(Object o) {
if (o instanceof PayRichEvent) {
use(((PayRichEvent) o).getA());
}
}
void use(String s) {}
}
class PayRichEvent { String getA() { return "PAY"; } }
class ShipRichEvent { String getA() { return "SHIP"; } }
""");
MethodInvocation getA = findInvocation(f.fire, "getA");
ReceiverFacts facts = InstanceofNarrower.factsAt(getA.getExpression(), getA, f.context);
assertThat(facts.uniqueOrNull()).isNotNull();
assertThat(facts.uniqueOrNull().typeFqn()).contains("PayRichEvent");
}
@Test
void outsideThenStaysEmpty(@TempDir Path tempDir) throws Exception {
Fixture f = scan(tempDir, """
package com.example;
class C {
void fire(Object o) {
if (o instanceof PayRichEvent p) {}
use(((PayRichEvent) o).getA());
}
void use(String s) {}
}
class PayRichEvent { String getA() { return "PAY"; } }
""");
MethodInvocation getA = findInvocation(f.fire, "getA");
ReceiverFacts facts = InstanceofNarrower.factsAt(getA.getExpression(), getA, f.context);
assertThat(facts.isEmpty()).isTrue();
}
@Test
void refineObjectCicWithInstanceof(@TempDir Path tempDir) throws Exception {
Fixture f = scan(tempDir, """
package com.example;
class C {
void fire(Object o) {
if (o instanceof PayRichEvent) {
use(((PayRichEvent) o).getA());
}
}
void use(String s) {}
}
class PayRichEvent { String getA() { return "PAY"; } }
""");
MethodInvocation getA = findInvocation(f.fire, "getA");
ReceiverFacts base = ReceiverFacts.of(new AllocationSite("java.lang.Object"));
ReceiverFacts refined = InstanceofNarrower.refine(base, getA.getExpression(), getA, f.context);
assertThat(refined.uniqueOrNull().typeFqn()).contains("PayRichEvent");
}
@Test
void refineConcreteConflictFailsClosed(@TempDir Path tempDir) throws Exception {
Fixture f = scan(tempDir, """
package com.example;
class C {
void fire(Object o) {
if (o instanceof PayRichEvent) {
use(((PayRichEvent) o).getA());
}
}
void use(String s) {}
}
class PayRichEvent { String getA() { return "PAY"; } }
class ShipRichEvent { String getA() { return "SHIP"; } }
""");
MethodInvocation getA = findInvocation(f.fire, "getA");
ReceiverFacts base = ReceiverFacts.of(new AllocationSite("com.example.ShipRichEvent"));
ReceiverFacts refined = InstanceofNarrower.refine(base, getA.getExpression(), getA, f.context);
assertThat(refined.isEmpty()).isTrue();
}
private static Fixture scan(Path tempDir, String source) throws Exception {
Files.writeString(tempDir.resolve("C.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration td = context.getTypeDeclaration("com.example.C");
if (td == null) {
td = context.getTypeDeclaration("C");
}
assertThat(td).isNotNull();
MethodDeclaration fire = context.findMethodDeclaration(td, "fire", true);
assertThat(fire).isNotNull();
return new Fixture(context, fire);
}
private static MethodInvocation findInvocation(MethodDeclaration method, String name) {
MethodInvocation[] found = {null};
method.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (name.equals(node.getName().getIdentifier())) {
found[0] = node;
return false;
}
return super.visit(node);
}
});
assertThat(found[0]).as("expected invocation %s", name).isNotNull();
return found[0];
}
private record Fixture(CodebaseContext context, MethodDeclaration fire) {
}
}

View File

@@ -0,0 +1,133 @@
package click.kamil.springstatemachineexporter.analysis.pipeline;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class MessageCarrierSupportTest {
@Test
void peelsWithPayload(@TempDir Path tempDir) throws Exception {
Expression sendArg = sendArg(tempDir, """
package com.example;
class C {
void fire() {
send(MessageBuilder.withPayload(OrderEvent.PAY).build());
}
void send(Object e) {}
}
enum OrderEvent { PAY }
class MessageBuilder {
static MessageBuilder withPayload(Object e) { return new MessageBuilder(); }
Object build() { return null; }
}
""");
Expression peeled = MessageCarrierSupport.peelEventFromMessageExpr(sendArg);
assertThat(peeled).isNotNull();
assertThat(peeled.toString()).contains("PAY");
}
@Test
void prefersAllowlistedHeaderOverPayload(@TempDir Path tempDir) throws Exception {
Expression sendArg = sendArg(tempDir, """
package com.example;
class C {
void fire() {
Object dto = new Object();
send(MessageBuilder.withPayload(dto).setHeader("event", OrderEvent.PAY).build());
}
void send(Object e) {}
}
enum OrderEvent { PAY }
class MessageBuilder {
static MessageBuilder withPayload(Object e) { return new MessageBuilder(); }
MessageBuilder setHeader(String k, Object v) { return this; }
Object build() { return null; }
}
""");
Expression peeled = MessageCarrierSupport.peelEventFromMessageExpr(sendArg);
assertThat(peeled).isNotNull();
assertThat(peeled.toString()).contains("PAY");
assertThat(MessageCarrierSupport.findSetHeaderValueOnChain(sendArg, "event").toString())
.contains("PAY");
}
@Test
void freeFormHeaderKeyIgnored(@TempDir Path tempDir) throws Exception {
Expression sendArg = sendArg(tempDir, """
package com.example;
class C {
void fire() {
Object dto = new Object();
send(MessageBuilder.withPayload(dto).setHeader("customKey", OrderEvent.PAY).build());
}
void send(Object e) {}
}
enum OrderEvent { PAY }
class MessageBuilder {
static MessageBuilder withPayload(Object e) { return new MessageBuilder(); }
MessageBuilder setHeader(String k, Object v) { return this; }
Object build() { return null; }
}
""");
assertThat(MessageCarrierSupport.findSetHeaderValueOnChain(sendArg, null)).isNull();
Expression peeled = MessageCarrierSupport.peelEventFromMessageExpr(sendArg);
// Falls through to dto payload — not the header const.
assertThat(peeled).isNotNull();
assertThat(peeled.toString()).doesNotContain("PAY");
}
@Test
void peelsGenericMessageCtor(@TempDir Path tempDir) throws Exception {
Expression sendArg = sendArg(tempDir, """
package com.example;
class C {
void fire() {
send(new GenericMessage<>(OrderEvent.PAY));
}
void send(Object e) {}
}
enum OrderEvent { PAY }
class GenericMessage<T> { GenericMessage(T payload) {} }
""");
Expression peeled = MessageCarrierSupport.peelEventFromMessageExpr(sendArg);
assertThat(peeled).isNotNull();
assertThat(peeled.toString()).contains("PAY");
}
private static Expression sendArg(Path tempDir, String source) throws Exception {
Files.writeString(tempDir.resolve("C.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration td = context.getTypeDeclaration("com.example.C");
if (td == null) {
td = context.getTypeDeclaration("C");
}
assertThat(td).isNotNull();
MethodDeclaration fire = context.findMethodDeclaration(td, "fire", true);
assertThat(fire).isNotNull();
MethodInvocation[] send = {null};
fire.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if ("send".equals(node.getName().getIdentifier()) && !node.arguments().isEmpty()) {
send[0] = node;
return false;
}
return super.visit(node);
}
});
assertThat(send[0]).isNotNull();
return (Expression) send[0].arguments().get(0);
}
}

View File

@@ -0,0 +1,181 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry;
import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner;
import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unique {@code @Component} implementing an interface field acts as CIC substitute for EIR.
*/
class BeanAsAllocEventIdentityTest {
@Test
void uniqueComponentBeanAsAllocResolves(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("App.java"), """
package com.example;
import org.springframework.stereotype.Component;
public class ApiController {
Dispatcher dispatcher;
public void pay() { dispatcher.fire(); }
}
class Dispatcher {
StateMachine machine;
RichEvent carrier;
void fire() {
machine.sendEvent(OrderEvent.valueOf(carrier.getA()));
}
}
interface RichEvent { String getA(); }
@Component
class PayRichEvent implements RichEvent {
public String getA() { return OrderEvent.PAY.name(); }
}
class ShipRichEvent implements RichEvent {
public String getA() { return OrderEvent.SHIP.name(); }
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
enum OrderEvent { PAY, SHIP, CANCEL }
enum OrderState { NEW, PAID, SHIPPED, CANCELLED }
class OrderStateMachineConfig {}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.setClasspath(ToolingClasspath.currentJvmJarEntries());
context.scan(Set.of(tempDir), Set.of());
SpringBeanRegistry registry = new SpringBeanRegistry();
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(new SpringContextScanner(registry));
}
InjectionPointAnalyzer injectionAnalyzer =
new InjectionPointAnalyzer(new SpringDependencyResolver(registry));
JdtCallGraphEngine engine = new JdtCallGraphEngine(context, injectionAnalyzer);
EntryPoint entry = EntryPoint.builder()
.className("com.example.ApiController")
.methodName("pay")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("e")
.build();
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
assertThat(chains).hasSize(1);
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
AnalysisResult result = AnalysisResult.builder()
.name("com.example.OrderStateMachineConfig")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(chains).build())
.build();
new TransitionLinkerEnricher().enrich(result, null, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(linked.getMatchedTransitions()).hasSize(1);
assertThat(linked.getMatchedTransitions().get(0).getEvent())
.isEqualTo("com.example.OrderEvent.PAY");
}
@Test
void multiComponentBeanStaysFailClosed(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("App.java"), """
package com.example;
import org.springframework.stereotype.Component;
public class ApiController {
Dispatcher dispatcher;
public void pay() { dispatcher.fire(); }
}
class Dispatcher {
StateMachine machine;
RichEvent carrier;
void fire() {
machine.sendEvent(OrderEvent.valueOf(carrier.getA()));
}
}
interface RichEvent { String getA(); }
@Component
class PayRichEvent implements RichEvent {
public String getA() { return OrderEvent.PAY.name(); }
}
@Component
class ShipRichEvent implements RichEvent {
public String getA() { return OrderEvent.SHIP.name(); }
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
enum OrderEvent { PAY, SHIP, CANCEL }
enum OrderState { NEW, PAID, SHIPPED, CANCELLED }
class OrderStateMachineConfig {}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.setClasspath(ToolingClasspath.currentJvmJarEntries());
context.scan(Set.of(tempDir), Set.of());
SpringBeanRegistry registry = new SpringBeanRegistry();
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(new SpringContextScanner(registry));
}
InjectionPointAnalyzer injectionAnalyzer =
new InjectionPointAnalyzer(new SpringDependencyResolver(registry));
JdtCallGraphEngine engine = new JdtCallGraphEngine(context, injectionAnalyzer);
EntryPoint entry = EntryPoint.builder()
.className("com.example.ApiController")
.methodName("pay")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("e")
.build();
List<CallChain> chains = engine.findChains(List.of(entry), List.of(trigger));
assertThat(chains).hasSize(1);
Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY");
Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP");
AnalysisResult result = AnalysisResult.builder()
.name("com.example.OrderStateMachineConfig")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(chains).build())
.build();
new TransitionLinkerEnricher().enrich(result, null, null);
CallChain linked = result.getMetadata().getCallChains().get(0);
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
assertThat(linked.getLinkResolution())
.isIn(LinkResolution.AMBIGUOUS_WIDEN, LinkResolution.NO_MATCH, LinkResolution.UNRESOLVED_EXTERNAL);
}
private static Transition transition(String source, String target, String eventFqn) {
Transition transition = new Transition();
transition.setSourceStates(List.of(State.of(source, source)));
transition.setTargetStates(List.of(State.of(target, target)));
transition.setEvent(Event.of(eventFqn, eventFqn));
return transition;
}
}