E
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
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.model.CallEdge;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
|
||||
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.CodebaseContext;
|
||||
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.Expression;
|
||||
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.ITypeBinding;
|
||||
import org.eclipse.jdt.core.dom.IVariableBinding;
|
||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||
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.ParenthesizedExpression;
|
||||
import org.eclipse.jdt.core.dom.SimpleName;
|
||||
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
|
||||
import org.eclipse.jdt.core.dom.ThisExpression;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
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,
|
||||
* 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).
|
||||
*/
|
||||
public final class AllocationFactPropagator {
|
||||
@@ -42,6 +51,7 @@ public final class AllocationFactPropagator {
|
||||
private final CodebaseContext context;
|
||||
private final VariableTracer variableTracer;
|
||||
private final ConstructorAnalyzer constructorAnalyzer;
|
||||
private InjectionPointAnalyzer injectionAnalyzer;
|
||||
|
||||
public AllocationFactPropagator(CodebaseContext context, VariableTracer variableTracer) {
|
||||
this(context, variableTracer, null);
|
||||
@@ -56,19 +66,25 @@ public final class AllocationFactPropagator {
|
||||
this.constructorAnalyzer = constructorAnalyzer;
|
||||
}
|
||||
|
||||
public void setInjectionAnalyzer(InjectionPointAnalyzer injectionAnalyzer) {
|
||||
this.injectionAnalyzer = injectionAnalyzer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocation facts for {@code expression} within {@code enclosingMethod}, optionally
|
||||
* refined by walking {@code methodPath} call edges backward for parameter receivers.
|
||||
* Dominating {@code instanceof} / pattern narrowing is merged via {@link InstanceofNarrower}.
|
||||
*/
|
||||
public ReceiverFacts factsAt(
|
||||
Expression expression,
|
||||
MethodDeclaration enclosingMethod,
|
||||
List<String> methodPath,
|
||||
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,
|
||||
MethodDeclaration enclosingMethod,
|
||||
List<String> methodPath,
|
||||
@@ -116,7 +132,7 @@ public final class AllocationFactPropagator {
|
||||
// Follow aliases / casts / factories: RichEvent carrier = e; e = (RichEvent) o;
|
||||
String defKey = "alias:" + System.identityHashCode(defPeeled);
|
||||
if (visited.add(defKey)) {
|
||||
ReceiverFacts nested = factsAt(
|
||||
ReceiverFacts nested = factsAtRaw(
|
||||
defPeeled, enclosingMethod, methodPath, callGraph, depth + 1, visited);
|
||||
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();
|
||||
}
|
||||
|
||||
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()}).
|
||||
* Uses the accessor invocation as instanceof use-site so then-branch narrowing applies.
|
||||
*/
|
||||
public ReceiverFacts factsForAccessorReceiver(
|
||||
MethodInvocation accessorCall,
|
||||
@@ -164,7 +231,9 @@ public final class AllocationFactPropagator {
|
||||
return ReceiverFacts.empty();
|
||||
}
|
||||
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) {
|
||||
@@ -207,22 +276,238 @@ public final class AllocationFactPropagator {
|
||||
return ReceiverFacts.empty();
|
||||
}
|
||||
try {
|
||||
ReceiverFacts receiverFacts = factsAt(
|
||||
ReceiverFacts receiverFacts = factsAtRaw(
|
||||
mi.getExpression(), enclosingMethod, methodPath, callGraph, depth + 1, visited);
|
||||
AllocationSite unique = receiverFacts.uniqueOrNull();
|
||||
if (unique == null || unique.cic() == null) {
|
||||
return ReceiverFacts.empty();
|
||||
if (unique != null && unique.cic() != null) {
|
||||
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());
|
||||
if (ctorArg == null) {
|
||||
return ReceiverFacts.empty();
|
||||
// Trivial setter peel: w.setEvent(new T()); … w.getEvent()
|
||||
String fieldName = javaBeansFieldName(mi.getName().getIdentifier());
|
||||
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 {
|
||||
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) {
|
||||
ClassInstanceCreation cic = site.cic();
|
||||
if (cic == null || getterName == null) {
|
||||
@@ -435,7 +720,7 @@ public final class AllocationFactPropagator {
|
||||
if (edge == null || edge.getTargetMethod() == null) {
|
||||
continue;
|
||||
}
|
||||
if (!methodsMatch(edge.getTargetMethod(), calleeMethodFqn)) {
|
||||
if (!CallSiteArgBinder.methodsMatch(edge.getTargetMethod(), calleeMethodFqn)) {
|
||||
continue;
|
||||
}
|
||||
List<String> args = edge.getArguments();
|
||||
@@ -443,8 +728,14 @@ public final class AllocationFactPropagator {
|
||||
continue;
|
||||
}
|
||||
// Prefer AST so nested CIC args stay attached for getter peels.
|
||||
ReceiverFacts nested = factsFromCallerAstArgument(
|
||||
callerMethodFqn, edge.getTargetMethod(), paramIndex, methodPath, callGraph, depth, visited);
|
||||
MethodDeclaration callerMd = findMethodDeclaration(callerMethodFqn);
|
||||
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()) {
|
||||
sites.addAll(nested.sites());
|
||||
continue;
|
||||
@@ -457,38 +748,6 @@ public final class AllocationFactPropagator {
|
||||
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) {
|
||||
if (cic == null) {
|
||||
return null;
|
||||
@@ -649,29 +908,18 @@ public final class AllocationFactPropagator {
|
||||
}
|
||||
|
||||
private static String methodNameOf(String methodFqn) {
|
||||
if (methodFqn == null) {
|
||||
return null;
|
||||
}
|
||||
int lastDot = methodFqn.lastIndexOf('.');
|
||||
return lastDot >= 0 ? methodFqn.substring(lastDot + 1) : methodFqn;
|
||||
return CallSiteArgBinder.methodNameOf(methodFqn);
|
||||
}
|
||||
|
||||
private static boolean methodsMatch(String left, String right) {
|
||||
if (left == null || right == null) {
|
||||
return false;
|
||||
}
|
||||
if (left.equals(right)) {
|
||||
return true;
|
||||
}
|
||||
return methodNameOf(left).equals(methodNameOf(right))
|
||||
&& (left.endsWith("." + methodNameOf(right)) || right.endsWith("." + methodNameOf(left)));
|
||||
return CallSiteArgBinder.methodsMatch(left, right);
|
||||
}
|
||||
|
||||
private static int indexOfMethodOnPath(List<String> methodPath, String methodFqn) {
|
||||
if (methodPath == null || methodFqn == null) {
|
||||
return -1;
|
||||
}
|
||||
String simple = methodNameOf(methodFqn);
|
||||
String simple = CallSiteArgBinder.methodNameOf(methodFqn);
|
||||
for (int i = methodPath.size() - 1; i >= 0; i--) {
|
||||
String hop = methodPath.get(i);
|
||||
if (hop != null && (hop.equals(methodFqn) || hop.endsWith("." + simple))) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,7 @@ public final class VirtualAccessorConstResolver {
|
||||
/**
|
||||
* 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()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ import java.util.Set;
|
||||
public final class LibraryUnwrapRegistry {
|
||||
|
||||
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(
|
||||
"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");
|
||||
|
||||
/** 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 = {
|
||||
"map", "parse", "convert", "to", "resolve", "build", "create", "from",
|
||||
"transform", "translate", "derive", "determine", "calculate", "decode",
|
||||
@@ -68,4 +71,20 @@ public final class LibraryUnwrapRegistry {
|
||||
public static boolean isHeaderSetterMethodName(String 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
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.eventid.EventIdentityResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
|
||||
@@ -39,6 +39,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
protected final PathBindingEvaluator pathBindingEvaluator;
|
||||
protected final AllocationFactPropagator allocationFactPropagator;
|
||||
protected final VirtualAccessorConstResolver virtualAccessorConstResolver;
|
||||
protected final EventIdentityResolver eventIdentityResolver;
|
||||
private final Map<String, ASTNode> parsedNodeCache = new HashMap<>();
|
||||
private final Map<String, List<String>> polymorphicCallCache = 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);
|
||||
this.virtualAccessorConstResolver = new VirtualAccessorConstResolver(
|
||||
context, accessorResolver, constantResolver);
|
||||
this.eventIdentityResolver = new EventIdentityResolver(
|
||||
context,
|
||||
allocationFactPropagator,
|
||||
virtualAccessorConstResolver,
|
||||
variableTracer,
|
||||
constantResolver);
|
||||
}
|
||||
|
||||
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
|
||||
@@ -96,6 +103,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
pathBindingEvaluator.clearAnalysisCaches();
|
||||
pathBindingsCache.clear();
|
||||
parsedNodeCache.clear();
|
||||
if (eventIdentityResolver != null) {
|
||||
eventIdentityResolver.clearSessionCache();
|
||||
}
|
||||
Map<String, List<CallEdge>> callGraph = buildCallGraph();
|
||||
List<CallChain> chains = new ArrayList<>();
|
||||
|
||||
@@ -1332,8 +1342,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, polymorphicEvents);
|
||||
}
|
||||
// valueOf(e.getType()) may have been collapsed to <SYMBOLIC:Enum.*> on the sendEvent
|
||||
// edge — recover via allocation-sensitive getType on the dispatcher frame.
|
||||
List<String> recovered = recoverAllocationSensitiveAccessorFromPath(path, callGraph);
|
||||
// edge — recover via event-identity resolution on the dispatcher frame.
|
||||
List<String> recovered = eventIdentityResolver != null
|
||||
? eventIdentityResolver.recoverFromPath(path, callGraph != null ? callGraph : this.graph)
|
||||
: List.of();
|
||||
if (recovered.size() == 1) {
|
||||
return buildTriggerPointWithPolymorphicEvents(
|
||||
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
|
||||
* concrete {@code new T()}, resolve only that override's compile-time constant instead of
|
||||
* unioning all interface implementors.
|
||||
* When a virtual accessor ({@code getType}/{@code getA}/…) has a receiver proven to be a
|
||||
* concrete {@code new T()} (or concrete declared type), resolve only that override's
|
||||
* compile-time constant instead of unioning all interface implementors.
|
||||
*
|
||||
* @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,
|
||||
Map<String, List<CallEdge>> callGraph,
|
||||
List<String> polymorphicEvents) {
|
||||
if (!(exprNode instanceof Expression expression)
|
||||
|| allocationFactPropagator == null
|
||||
|| virtualAccessorConstResolver == null) {
|
||||
if (!(exprNode instanceof Expression expression) || eventIdentityResolver == null) {
|
||||
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;
|
||||
ReceiverFacts facts = allocationFactPropagator.factsForAccessorReceiver(
|
||||
accessorCall, path, graphForFacts);
|
||||
List<String> unique = virtualAccessorConstResolver.resolveUniqueConstant(facts, accessorName);
|
||||
List<String> unique = eventIdentityResolver.resolveUnique(expression, path, graphForFacts);
|
||||
if (unique.size() != 1) {
|
||||
// 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) {
|
||||
return false;
|
||||
}
|
||||
@@ -1733,7 +1745,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
}
|
||||
if (peeled instanceof MethodInvocation mi) {
|
||||
String name = mi.getName().getIdentifier();
|
||||
if (preferredName != null && preferredName.equals(name) && isVirtualAccessorName(name)) {
|
||||
if (preferredName != null && preferredName.equals(name)
|
||||
&& EventIdentityResolver.isVirtualAccessorName(name)) {
|
||||
return mi;
|
||||
}
|
||||
if ("valueOf".equals(name) && mi.arguments().size() == 1
|
||||
@@ -1743,10 +1756,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
if (preferredName == null && isVirtualAccessorName(name)) {
|
||||
if (preferredName == null && EventIdentityResolver.isVirtualAccessorName(name)) {
|
||||
return mi;
|
||||
}
|
||||
if (isVirtualAccessorName(name)) {
|
||||
if (EventIdentityResolver.isVirtualAccessorName(name)) {
|
||||
return mi;
|
||||
}
|
||||
}
|
||||
@@ -1754,21 +1767,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
}
|
||||
|
||||
private static boolean isVirtualAccessorName(String name) {
|
||||
if (name == null || name.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
return isEventIdentityAccessorName(name)
|
||||
|| (name.startsWith("get") && name.length() > 3);
|
||||
return EventIdentityResolver.isVirtualAccessorName(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessors that identify the machine event (not arbitrary getters on the same frame).
|
||||
*/
|
||||
private static boolean isEventIdentityAccessorName(String name) {
|
||||
return "getType".equals(name)
|
||||
|| "getEvent".equals(name)
|
||||
|| "getId".equals(name)
|
||||
|| "resolveEventCode".equals(name);
|
||||
return EventIdentityResolver.isEventIdentityAccessorName(name);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1798,7 +1801,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
List<String> path,
|
||||
Map<String, List<CallEdge>> callGraph,
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
@@ -1807,91 +1814,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
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) {
|
||||
MethodDeclaration enclosing = findEnclosingMethod(getterCall);
|
||||
if (enclosing == null || enclosing.getBody() == null) {
|
||||
@@ -2296,6 +2218,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
resolvedValue, methodSuffix);
|
||||
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);
|
||||
if (!constants.isEmpty()) {
|
||||
return constants;
|
||||
@@ -2305,6 +2234,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
String remapped = ReactiveExpressionSupport.remapLambdaParameterGetterInMethod(
|
||||
expressionText, scopeMethod, constantResolver, context);
|
||||
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);
|
||||
if (!constants.isEmpty()) {
|
||||
return constants;
|
||||
@@ -2314,6 +2247,23 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
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(
|
||||
String expressionText,
|
||||
List<String> searchMethods,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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.spring.InjectionPointAnalyzer;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
@@ -13,6 +14,9 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
|
||||
public JdtCallGraphEngine(CodebaseContext context, InjectionPointAnalyzer injectionAnalyzer) {
|
||||
super(context);
|
||||
this.injectionAnalyzer = injectionAnalyzer;
|
||||
if (allocationFactPropagator != null) {
|
||||
allocationFactPropagator.setInjectionAnalyzer(injectionAnalyzer);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -175,7 +179,11 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
|
||||
}
|
||||
|
||||
private Expression unwrapMessageBuilder(Expression expr) {
|
||||
Expression peeled = ReactiveExpressionSupport.peelFactoryPayloadExpression(expr);
|
||||
return peeled != null ? peeled : expr;
|
||||
Expression peeled = MessageCarrierSupport.peelEventFromMessageExpr(expr);
|
||||
if (peeled != null) {
|
||||
return peeled;
|
||||
}
|
||||
Expression factory = ReactiveExpressionSupport.peelFactoryPayloadExpression(expr);
|
||||
return factory != null ? factory : expr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user