E
This commit is contained in:
@@ -0,0 +1,683 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.flow.alloc;
|
||||||
|
|
||||||
|
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.ast.common.AstUtils;
|
||||||
|
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.Assignment;
|
||||||
|
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.IMethodBinding;
|
||||||
|
import org.eclipse.jdt.core.dom.ITypeBinding;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodInvocation;
|
||||||
|
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 java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
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.
|
||||||
|
* Fail-closed when facts are empty or ambiguous (multi-site).
|
||||||
|
*/
|
||||||
|
public final class AllocationFactPropagator {
|
||||||
|
|
||||||
|
private static final int MAX_DEPTH = 8;
|
||||||
|
|
||||||
|
private final CodebaseContext context;
|
||||||
|
private final VariableTracer variableTracer;
|
||||||
|
private final ConstructorAnalyzer constructorAnalyzer;
|
||||||
|
|
||||||
|
public AllocationFactPropagator(CodebaseContext context, VariableTracer variableTracer) {
|
||||||
|
this(context, variableTracer, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AllocationFactPropagator(
|
||||||
|
CodebaseContext context,
|
||||||
|
VariableTracer variableTracer,
|
||||||
|
ConstructorAnalyzer constructorAnalyzer) {
|
||||||
|
this.context = context;
|
||||||
|
this.variableTracer = variableTracer;
|
||||||
|
this.constructorAnalyzer = constructorAnalyzer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allocation facts for {@code expression} within {@code enclosingMethod}, optionally
|
||||||
|
* refined by walking {@code methodPath} call edges backward for parameter receivers.
|
||||||
|
*/
|
||||||
|
public ReceiverFacts factsAt(
|
||||||
|
Expression expression,
|
||||||
|
MethodDeclaration enclosingMethod,
|
||||||
|
List<String> methodPath,
|
||||||
|
Map<String, List<CallEdge>> callGraph) {
|
||||||
|
return factsAt(expression, enclosingMethod, methodPath, callGraph, 0, new LinkedHashSet<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReceiverFacts factsAt(
|
||||||
|
Expression expression,
|
||||||
|
MethodDeclaration enclosingMethod,
|
||||||
|
List<String> methodPath,
|
||||||
|
Map<String, List<CallEdge>> callGraph,
|
||||||
|
int depth,
|
||||||
|
Set<String> visited) {
|
||||||
|
if (expression == null || depth > MAX_DEPTH) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
Expression peeled = peel(expression);
|
||||||
|
LinkedHashSet<AllocationSite> sites = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
if (peeled instanceof ClassInstanceCreation cic) {
|
||||||
|
AllocationSite site = siteFromCic(cic);
|
||||||
|
if (site != null) {
|
||||||
|
sites.add(site);
|
||||||
|
}
|
||||||
|
return ReceiverFacts.ofAll(sites);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peeled instanceof MethodInvocation mi) {
|
||||||
|
ReceiverFacts fromGetter = factsFromGetterPeel(
|
||||||
|
mi, enclosingMethod, methodPath, callGraph, depth, visited);
|
||||||
|
if (!fromGetter.isEmpty()) {
|
||||||
|
return fromGetter;
|
||||||
|
}
|
||||||
|
ReceiverFacts fromFactory = factsFromFactoryReturn(mi, depth, visited);
|
||||||
|
if (!fromFactory.isEmpty()) {
|
||||||
|
return fromFactory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variableTracer != null) {
|
||||||
|
for (Expression def : variableTracer.traceVariableAll(peeled)) {
|
||||||
|
Expression defPeeled = peel(def);
|
||||||
|
if (defPeeled == null || defPeeled == peeled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (defPeeled instanceof ClassInstanceCreation cic) {
|
||||||
|
AllocationSite site = siteFromCic(cic);
|
||||||
|
if (site != null) {
|
||||||
|
sites.add(site);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Follow aliases / casts / factories: RichEvent carrier = e; e = (RichEvent) o;
|
||||||
|
String defKey = "alias:" + System.identityHashCode(defPeeled);
|
||||||
|
if (visited.add(defKey)) {
|
||||||
|
ReceiverFacts nested = factsAt(
|
||||||
|
defPeeled, enclosingMethod, methodPath, callGraph, depth + 1, visited);
|
||||||
|
sites.addAll(nested.sites());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sites.isEmpty()) {
|
||||||
|
return ReceiverFacts.ofAll(sites);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parameter: walk call path to find caller argument CIC.
|
||||||
|
if (peeled instanceof SimpleName simpleName
|
||||||
|
&& enclosingMethod != null
|
||||||
|
&& methodPath != null
|
||||||
|
&& callGraph != null) {
|
||||||
|
int paramIndex = parameterIndex(enclosingMethod, simpleName.getIdentifier());
|
||||||
|
if (paramIndex >= 0) {
|
||||||
|
String calleeFqn = methodFqn(enclosingMethod);
|
||||||
|
if (calleeFqn != null && visited.add(calleeFqn + "#" + paramIndex)) {
|
||||||
|
ReceiverFacts fromCaller = factsFromCallerArgument(
|
||||||
|
calleeFqn, paramIndex, methodPath, callGraph, depth + 1, visited);
|
||||||
|
if (!fromCaller.isEmpty()) {
|
||||||
|
return fromCaller;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Facts for the receiver of a virtual accessor call ({@code e.getType()}).
|
||||||
|
*/
|
||||||
|
public ReceiverFacts factsForAccessorReceiver(
|
||||||
|
MethodInvocation accessorCall,
|
||||||
|
List<String> methodPath,
|
||||||
|
Map<String, List<CallEdge>> callGraph) {
|
||||||
|
if (accessorCall == null) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
Expression receiver = accessorCall.getExpression();
|
||||||
|
if (receiver == null) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
MethodDeclaration enclosing = findEnclosingMethod(accessorCall);
|
||||||
|
return factsAt(receiver, enclosing, methodPath, callGraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReceiverFacts factsFromFactoryReturn(MethodInvocation mi, int depth, Set<String> visited) {
|
||||||
|
if (constructorAnalyzer == null || mi == null || depth > MAX_DEPTH) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
String owner = resolveInvocationOwner(mi);
|
||||||
|
if (owner == null || owner.isBlank()) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
String key = "factory:" + owner + "#" + mi.getName().getIdentifier();
|
||||||
|
if (!visited.add(key)) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ClassInstanceCreation cic = constructorAnalyzer.findReturnedCIC(
|
||||||
|
owner, mi.getName().getIdentifier(), context);
|
||||||
|
if (cic == null) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
AllocationSite site = siteFromCic(cic);
|
||||||
|
return site == null ? ReceiverFacts.empty() : ReceiverFacts.of(site);
|
||||||
|
} finally {
|
||||||
|
visited.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReceiverFacts factsFromGetterPeel(
|
||||||
|
MethodInvocation mi,
|
||||||
|
MethodDeclaration enclosingMethod,
|
||||||
|
List<String> methodPath,
|
||||||
|
Map<String, List<CallEdge>> callGraph,
|
||||||
|
int depth,
|
||||||
|
Set<String> visited) {
|
||||||
|
if (mi.getExpression() == null) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
String peelKey = "getter:" + mi.getName().getIdentifier() + "@" + System.identityHashCode(mi);
|
||||||
|
if (!visited.add(peelKey)) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ReceiverFacts receiverFacts = factsAt(
|
||||||
|
mi.getExpression(), enclosingMethod, methodPath, callGraph, depth + 1, visited);
|
||||||
|
AllocationSite unique = receiverFacts.uniqueOrNull();
|
||||||
|
if (unique == null || unique.cic() == null) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
Expression ctorArg = resolveGetterToCtorArg(unique, mi.getName().getIdentifier());
|
||||||
|
if (ctorArg == null) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
return factsAt(ctorArg, enclosingMethod, methodPath, callGraph, depth + 1, visited);
|
||||||
|
} finally {
|
||||||
|
visited.remove(peelKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Expression resolveGetterToCtorArg(AllocationSite site, String getterName) {
|
||||||
|
ClassInstanceCreation cic = site.cic();
|
||||||
|
if (cic == null || getterName == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TypeDeclaration td = resolveTypeDeclaration(site.typeFqn());
|
||||||
|
if (td == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String fieldName = fieldNameForGetter(site.typeFqn(), getterName);
|
||||||
|
if (fieldName == null || fieldName.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int idx = findCtorArgIndexForField(td, cic, fieldName);
|
||||||
|
if (idx < 0 || idx >= cic.arguments().size()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (Expression) cic.arguments().get(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fieldNameForGetter(String ownerFqn, String getterName) {
|
||||||
|
if (context.getAccessorIndex() != null) {
|
||||||
|
Optional<AccessorSummary> indexed = context.getAccessorIndex().lookup(ownerFqn, getterName);
|
||||||
|
if (indexed.isPresent() && indexed.get().fieldName() != null) {
|
||||||
|
return indexed.get().fieldName();
|
||||||
|
}
|
||||||
|
String simple = simplifyTypeName(ownerFqn);
|
||||||
|
if (!simple.equals(ownerFqn)) {
|
||||||
|
indexed = context.getAccessorIndex().lookup(simple, getterName);
|
||||||
|
if (indexed.isPresent() && indexed.get().fieldName() != null) {
|
||||||
|
return indexed.get().fieldName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return javaBeansFieldName(getterName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String javaBeansFieldName(String getterName) {
|
||||||
|
if (getterName == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (getterName.startsWith("get") && getterName.length() > 3) {
|
||||||
|
return Character.toLowerCase(getterName.charAt(3)) + getterName.substring(4);
|
||||||
|
}
|
||||||
|
if (getterName.startsWith("is") && getterName.length() > 2) {
|
||||||
|
return Character.toLowerCase(getterName.charAt(2)) + getterName.substring(3);
|
||||||
|
}
|
||||||
|
return getterName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int findCtorArgIndexForField(TypeDeclaration td, ClassInstanceCreation cic, String fieldName) {
|
||||||
|
IMethodBinding resolvedCtor = cic.resolveConstructorBinding();
|
||||||
|
for (MethodDeclaration ctor : td.getMethods()) {
|
||||||
|
if (!ctor.isConstructor() || ctor.getBody() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
boolean matches = false;
|
||||||
|
if (resolvedCtor != null && ctor.resolveBinding() != null) {
|
||||||
|
matches = resolvedCtor.isEqualTo(ctor.resolveBinding())
|
||||||
|
|| resolvedCtor.getKey().equals(ctor.resolveBinding().getKey());
|
||||||
|
}
|
||||||
|
if (!matches) {
|
||||||
|
matches = ctor.parameters().size() == cic.arguments().size();
|
||||||
|
}
|
||||||
|
if (!matches) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int fromBody = ctorParamIndexAssignedToField(ctor, fieldName);
|
||||||
|
if (fromBody >= 0) {
|
||||||
|
return fromBody;
|
||||||
|
}
|
||||||
|
// Same-name param fallback: EventWrapper(RichEvent event) { this.event = event; }
|
||||||
|
for (int i = 0; i < ctor.parameters().size(); i++) {
|
||||||
|
SingleVariableDeclaration svd = (SingleVariableDeclaration) ctor.parameters().get(i);
|
||||||
|
if (fieldName.equals(svd.getName().getIdentifier())) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (constructorAnalyzer != null) {
|
||||||
|
int lombokIdx = constructorAnalyzer.findConstructorArgumentIndexForLombokField(td, fieldName);
|
||||||
|
if (lombokIdx >= 0) {
|
||||||
|
return lombokIdx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Single-arg ctor → assume it initializes the sole relevant field.
|
||||||
|
if (cic.arguments().size() == 1 && td.getMethods() != null) {
|
||||||
|
for (MethodDeclaration ctor : td.getMethods()) {
|
||||||
|
if (ctor.isConstructor() && ctor.parameters().size() == 1) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ctorParamIndexAssignedToField(MethodDeclaration ctor, String fieldName) {
|
||||||
|
int[] found = {-1};
|
||||||
|
List<?> params = ctor.parameters();
|
||||||
|
ctor.getBody().accept(new ASTVisitor() {
|
||||||
|
@Override
|
||||||
|
public boolean visit(Assignment node) {
|
||||||
|
if (!(node.getRightHandSide() instanceof SimpleName rhs)) {
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
String paramName = rhs.getIdentifier();
|
||||||
|
int paramIdx = -1;
|
||||||
|
for (int i = 0; i < params.size(); i++) {
|
||||||
|
if (params.get(i) instanceof SingleVariableDeclaration svd
|
||||||
|
&& paramName.equals(svd.getName().getIdentifier())) {
|
||||||
|
paramIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (paramIdx < 0) {
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
Expression lhs = node.getLeftHandSide();
|
||||||
|
if (lhs instanceof SimpleName sn && fieldName.equals(sn.getIdentifier())) {
|
||||||
|
found[0] = paramIdx;
|
||||||
|
} else if (lhs instanceof FieldAccess fa
|
||||||
|
&& fa.getExpression() instanceof ThisExpression
|
||||||
|
&& fieldName.equals(fa.getName().getIdentifier())) {
|
||||||
|
found[0] = paramIdx;
|
||||||
|
}
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return found[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveInvocationOwner(MethodInvocation mi) {
|
||||||
|
Expression expr = mi.getExpression();
|
||||||
|
if (expr == null) {
|
||||||
|
MethodDeclaration enclosing = findEnclosingMethod(mi);
|
||||||
|
TypeDeclaration td = enclosing != null ? findEnclosingType(enclosing) : null;
|
||||||
|
return td != null ? context.getFqn(td) : null;
|
||||||
|
}
|
||||||
|
ITypeBinding binding = expr.resolveTypeBinding();
|
||||||
|
if (binding != null) {
|
||||||
|
ITypeBinding erasure = binding.getErasure();
|
||||||
|
if (erasure != null && erasure.getQualifiedName() != null && !erasure.getQualifiedName().isBlank()) {
|
||||||
|
// Static type name as qualifier often binds to the type itself.
|
||||||
|
return erasure.getQualifiedName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (expr instanceof SimpleName sn) {
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(sn.getIdentifier());
|
||||||
|
if (td != null) {
|
||||||
|
String fqn = context.getFqn(td);
|
||||||
|
if (fqn != null) {
|
||||||
|
return fqn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CompilationUnitAwareFqn local = resolveSimpleInEnclosingCu(mi, sn.getIdentifier());
|
||||||
|
if (local != null) {
|
||||||
|
return local.fqn();
|
||||||
|
}
|
||||||
|
return sn.getIdentifier();
|
||||||
|
}
|
||||||
|
if (expr instanceof Name name) {
|
||||||
|
return name.getFullyQualifiedName();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TypeDeclaration resolveTypeDeclaration(String typeFqn) {
|
||||||
|
if (typeFqn == null || typeFqn.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(typeFqn);
|
||||||
|
if (td != null) {
|
||||||
|
return td;
|
||||||
|
}
|
||||||
|
return context.getTypeDeclaration(simplifyTypeName(typeFqn));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String simplifyTypeName(String typeFqn) {
|
||||||
|
if (typeFqn == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int lastDot = typeFqn.lastIndexOf('.');
|
||||||
|
return lastDot >= 0 ? typeFqn.substring(lastDot + 1) : typeFqn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReceiverFacts factsFromCallerArgument(
|
||||||
|
String calleeMethodFqn,
|
||||||
|
int paramIndex,
|
||||||
|
List<String> methodPath,
|
||||||
|
Map<String, List<CallEdge>> callGraph,
|
||||||
|
int depth,
|
||||||
|
Set<String> visited) {
|
||||||
|
if (calleeMethodFqn == null || methodPath == null || callGraph == null) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
int calleeIdx = methodPath.indexOf(calleeMethodFqn);
|
||||||
|
if (calleeIdx <= 0) {
|
||||||
|
calleeIdx = indexOfMethodOnPath(methodPath, calleeMethodFqn);
|
||||||
|
}
|
||||||
|
if (calleeIdx <= 0) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
String callerMethodFqn = methodPath.get(calleeIdx - 1);
|
||||||
|
List<CallEdge> edges = callGraph.get(callerMethodFqn);
|
||||||
|
if (edges == null) {
|
||||||
|
return ReceiverFacts.empty();
|
||||||
|
}
|
||||||
|
LinkedHashSet<AllocationSite> sites = new LinkedHashSet<>();
|
||||||
|
for (CallEdge edge : edges) {
|
||||||
|
if (edge == null || edge.getTargetMethod() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!methodsMatch(edge.getTargetMethod(), calleeMethodFqn)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<String> args = edge.getArguments();
|
||||||
|
if (args == null || paramIndex >= args.size()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Prefer AST so nested CIC args stay attached for getter peels.
|
||||||
|
ReceiverFacts nested = factsFromCallerAstArgument(
|
||||||
|
callerMethodFqn, edge.getTargetMethod(), paramIndex, methodPath, callGraph, depth, visited);
|
||||||
|
if (!nested.isEmpty()) {
|
||||||
|
sites.addAll(nested.sites());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
AllocationSite fromArg = siteFromArgumentText(args.get(paramIndex));
|
||||||
|
if (fromArg != null) {
|
||||||
|
sites.add(fromArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
ITypeBinding binding = cic.resolveTypeBinding();
|
||||||
|
if (binding != null) {
|
||||||
|
ITypeBinding erasure = binding.getErasure();
|
||||||
|
if (erasure != null && erasure.getQualifiedName() != null && !erasure.getQualifiedName().isBlank()) {
|
||||||
|
return new AllocationSite(erasure.getQualifiedName(), cic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cic.resolveConstructorBinding() != null
|
||||||
|
&& cic.resolveConstructorBinding().getDeclaringClass() != null) {
|
||||||
|
String qn = cic.resolveConstructorBinding().getDeclaringClass().getErasure().getQualifiedName();
|
||||||
|
if (qn != null && !qn.isBlank()) {
|
||||||
|
return new AllocationSite(qn, cic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String simple = AstUtils.extractSimpleTypeName(cic.getType());
|
||||||
|
if (simple == null || simple.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(simple);
|
||||||
|
if (td != null) {
|
||||||
|
String fqn = context.getFqn(td);
|
||||||
|
if (fqn != null && !fqn.isBlank()) {
|
||||||
|
return new AllocationSite(fqn, cic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CompilationUnitAwareFqn resolved = resolveSimpleInEnclosingCu(cic, simple);
|
||||||
|
if (resolved != null) {
|
||||||
|
return new AllocationSite(resolved.fqn(), cic);
|
||||||
|
}
|
||||||
|
return new AllocationSite(simple, cic);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CompilationUnitAwareFqn(String fqn) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompilationUnitAwareFqn resolveSimpleInEnclosingCu(ASTNode node, String simple) {
|
||||||
|
if (node == null || simple == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ASTNode root = node.getRoot();
|
||||||
|
if (!(root instanceof org.eclipse.jdt.core.dom.CompilationUnit cu)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(simple, cu);
|
||||||
|
if (td == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String fqn = context.getFqn(td);
|
||||||
|
return fqn == null || fqn.isBlank() ? null : new CompilationUnitAwareFqn(fqn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AllocationSite siteFromArgumentText(String arg) {
|
||||||
|
if (arg == null || arg.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = arg.trim();
|
||||||
|
if (!trimmed.startsWith("new ")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String rest = trimmed.substring(4).trim();
|
||||||
|
int paren = rest.indexOf('(');
|
||||||
|
int brace = rest.indexOf('{');
|
||||||
|
int end = rest.length();
|
||||||
|
if (paren >= 0) {
|
||||||
|
end = Math.min(end, paren);
|
||||||
|
}
|
||||||
|
if (brace >= 0) {
|
||||||
|
end = Math.min(end, brace);
|
||||||
|
}
|
||||||
|
String type = rest.substring(0, end).trim();
|
||||||
|
if (type.isBlank() || type.contains(" ")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int generic = type.indexOf('<');
|
||||||
|
if (generic >= 0) {
|
||||||
|
type = type.substring(0, generic);
|
||||||
|
}
|
||||||
|
return new AllocationSite(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int parameterIndex(MethodDeclaration method, String name) {
|
||||||
|
if (method == null || name == null) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
List<?> params = method.parameters();
|
||||||
|
for (int i = 0; i < params.size(); i++) {
|
||||||
|
if (params.get(i) instanceof SingleVariableDeclaration svd
|
||||||
|
&& name.equals(svd.getName().getIdentifier())) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String methodFqn(MethodDeclaration method) {
|
||||||
|
if (method == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
TypeDeclaration td = findEnclosingType(method);
|
||||||
|
if (td == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String typeFqn = context.getFqn(td);
|
||||||
|
if (typeFqn == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return typeFqn + "." + method.getName().getIdentifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MethodDeclaration findMethodDeclaration(String methodFqn) {
|
||||||
|
if (methodFqn == null || !methodFqn.contains(".")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
|
||||||
|
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(className);
|
||||||
|
if (td == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return context.findMethodDeclaration(td, methodName, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 TypeDeclaration findEnclosingType(ASTNode node) {
|
||||||
|
ASTNode current = node;
|
||||||
|
while (current != null) {
|
||||||
|
if (current instanceof TypeDeclaration td) {
|
||||||
|
return td;
|
||||||
|
}
|
||||||
|
current = current.getParent();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String methodNameOf(String methodFqn) {
|
||||||
|
if (methodFqn == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int lastDot = methodFqn.lastIndexOf('.');
|
||||||
|
return lastDot >= 0 ? methodFqn.substring(lastDot + 1) : 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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int indexOfMethodOnPath(List<String> methodPath, String methodFqn) {
|
||||||
|
if (methodPath == null || methodFqn == null) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
String simple = 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))) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.flow.alloc;
|
||||||
|
|
||||||
|
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A concrete {@code new T(...)} allocation site relevant to receiver-type narrowing.
|
||||||
|
* Optional CIC AST is retained for getter peels (ctor args) but is not part of equality —
|
||||||
|
* uniqueness remains by type FQN.
|
||||||
|
*/
|
||||||
|
public final class AllocationSite {
|
||||||
|
|
||||||
|
private final String typeFqn;
|
||||||
|
private final ClassInstanceCreation cic;
|
||||||
|
|
||||||
|
public AllocationSite(String typeFqn) {
|
||||||
|
this(typeFqn, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AllocationSite(String typeFqn, ClassInstanceCreation cic) {
|
||||||
|
this.typeFqn = typeFqn;
|
||||||
|
this.cic = cic;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String typeFqn() {
|
||||||
|
return typeFqn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source CIC when known (for nested ctor-arg peels). May be null for text-only sites.
|
||||||
|
*/
|
||||||
|
public ClassInstanceCreation cic() {
|
||||||
|
return cic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefer the site that retains CIC AST when merging equal type FQNs.
|
||||||
|
*/
|
||||||
|
public AllocationSite preferRich(AllocationSite other) {
|
||||||
|
if (other == null) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
if (!Objects.equals(typeFqn, other.typeFqn)) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
if (this.cic == null && other.cic != null) {
|
||||||
|
return other;
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (this == o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!(o instanceof AllocationSite that)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Objects.equals(typeFqn, that.typeFqn);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(typeFqn);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "AllocationSite{" + typeFqn + (cic != null ? "+cic" : "") + '}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.flow.alloc;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* May-set of concrete allocation sites for a receiver expression / parameter.
|
||||||
|
* Empty or multi-site facts fail closed (do not narrow virtual accessors).
|
||||||
|
*/
|
||||||
|
public final class ReceiverFacts {
|
||||||
|
|
||||||
|
private final Set<AllocationSite> sites;
|
||||||
|
|
||||||
|
private ReceiverFacts(Set<AllocationSite> sites) {
|
||||||
|
this.sites = sites == null ? Set.of() : Set.copyOf(sites);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReceiverFacts empty() {
|
||||||
|
return new ReceiverFacts(Set.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReceiverFacts of(AllocationSite site) {
|
||||||
|
if (site == null || site.typeFqn() == null || site.typeFqn().isBlank()) {
|
||||||
|
return empty();
|
||||||
|
}
|
||||||
|
return new ReceiverFacts(Set.of(site));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ReceiverFacts ofAll(Iterable<AllocationSite> sites) {
|
||||||
|
// Merge by type FQN, preferring sites that retain CIC AST.
|
||||||
|
Map<String, AllocationSite> byType = new LinkedHashMap<>();
|
||||||
|
if (sites != null) {
|
||||||
|
for (AllocationSite site : sites) {
|
||||||
|
if (site == null || site.typeFqn() == null || site.typeFqn().isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
AllocationSite existing = byType.get(site.typeFqn());
|
||||||
|
byType.put(site.typeFqn(), existing == null ? site : existing.preferRich(site));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ReceiverFacts(Set.copyOf(byType.values()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return sites.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isUnique() {
|
||||||
|
return sites.size() == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AllocationSite uniqueOrNull() {
|
||||||
|
return isUnique() ? sites.iterator().next() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AllocationSite> sites() {
|
||||||
|
return List.copyOf(sites);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int size() {
|
||||||
|
return sites.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.flow.alloc;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import org.eclipse.jdt.core.dom.CompilationUnit;
|
||||||
|
import org.eclipse.jdt.core.dom.Expression;
|
||||||
|
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.ReturnStatement;
|
||||||
|
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||||
|
import org.eclipse.jdt.core.dom.ASTVisitor;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given concrete allocation facts and an accessor name ({@code getType}/{@code getEvent}/…),
|
||||||
|
* resolve overrides and extract compile-time return constants. Succeeds only when exactly one
|
||||||
|
* constant is proven across all facts.
|
||||||
|
*/
|
||||||
|
public final class VirtualAccessorConstResolver {
|
||||||
|
|
||||||
|
private final CodebaseContext context;
|
||||||
|
private final AccessorResolver accessorResolver;
|
||||||
|
private final ConstantResolver constantResolver;
|
||||||
|
|
||||||
|
public VirtualAccessorConstResolver(
|
||||||
|
CodebaseContext context,
|
||||||
|
AccessorResolver accessorResolver,
|
||||||
|
ConstantResolver constantResolver) {
|
||||||
|
this.context = context;
|
||||||
|
this.accessorResolver = accessorResolver;
|
||||||
|
this.constantResolver = constantResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return singleton list with the proven constant, or empty when unknown / ambiguous / non-constant
|
||||||
|
*/
|
||||||
|
public List<String> resolveUniqueConstant(ReceiverFacts facts, String accessorName) {
|
||||||
|
if (facts == null || facts.isEmpty() || accessorName == null || accessorName.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
LinkedHashSet<String> constants = new LinkedHashSet<>();
|
||||||
|
for (AllocationSite site : facts.sites()) {
|
||||||
|
List<String> fromSite = constantsForSite(site, accessorName);
|
||||||
|
if (fromSite.isEmpty()) {
|
||||||
|
// Any site without a compile-time constant → fail closed.
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
constants.addAll(fromSite);
|
||||||
|
if (constants.size() > 1) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (constants.size() != 1) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return List.copyOf(constants);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> constantsForSite(AllocationSite site, String accessorName) {
|
||||||
|
if (site == null || site.typeFqn() == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
String typeFqn = resolveTypeFqn(site.typeFqn());
|
||||||
|
if (typeFqn == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(typeFqn);
|
||||||
|
CompilationUnit cu = td != null && td.getRoot() instanceof CompilationUnit c ? c : null;
|
||||||
|
|
||||||
|
List<String> viaAccessor = List.of();
|
||||||
|
if (accessorResolver != null) {
|
||||||
|
viaAccessor = accessorResolver.resolve(
|
||||||
|
typeFqn,
|
||||||
|
accessorName,
|
||||||
|
new AccessorResolver.ReceiverContext(td, null, cu, false, null),
|
||||||
|
ResolutionBudget.defaults());
|
||||||
|
}
|
||||||
|
LinkedHashSet<String> cleaned = new LinkedHashSet<>();
|
||||||
|
for (String value : viaAccessor) {
|
||||||
|
String normalized = normalizeConstant(value);
|
||||||
|
if (normalized != null) {
|
||||||
|
cleaned.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cleaned.size() == 1) {
|
||||||
|
return List.copyOf(cleaned);
|
||||||
|
}
|
||||||
|
if (cleaned.size() > 1) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: evaluate return expressions with ConstantResolver (handles Enum.CONST.name()).
|
||||||
|
if (td == null || constantResolver == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
MethodDeclaration method = context.findMethodDeclaration(td, accessorName, true);
|
||||||
|
if (method == null || method.getBody() == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<String> fromReturns = new ArrayList<>();
|
||||||
|
method.getBody().accept(new ASTVisitor() {
|
||||||
|
@Override
|
||||||
|
public boolean visit(ReturnStatement node) {
|
||||||
|
Expression expr = node.getExpression();
|
||||||
|
if (expr == null) {
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
String resolved = constantResolver.resolve(expr, context);
|
||||||
|
String normalized = normalizeConstant(resolved);
|
||||||
|
if (normalized != null) {
|
||||||
|
fromReturns.add(normalized);
|
||||||
|
}
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
LinkedHashSet<String> unique = new LinkedHashSet<>(fromReturns);
|
||||||
|
if (unique.size() == 1) {
|
||||||
|
return List.copyOf(unique);
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveTypeFqn(String typeName) {
|
||||||
|
if (typeName == null || typeName.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeName.contains(".")) {
|
||||||
|
if (context.getTypeDeclaration(typeName) != null) {
|
||||||
|
return typeName;
|
||||||
|
}
|
||||||
|
// May be simple-with-package missing; still try as-is for resolve.
|
||||||
|
return typeName;
|
||||||
|
}
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration(typeName);
|
||||||
|
if (td != null) {
|
||||||
|
String fqn = context.getFqn(td);
|
||||||
|
return fqn != null ? fqn : typeName;
|
||||||
|
}
|
||||||
|
return typeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop symbolic / enum-set / blank; keep FQN or bare constant identifiers.
|
||||||
|
*/
|
||||||
|
static String normalizeConstant(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value.startsWith("<SYMBOLIC:") || value.startsWith("ENUM_SET:") || value.contains(".*>")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = value.trim();
|
||||||
|
// Enum.CONST.name() peel may already be bare; QualName may be FQN.
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package click.kamil.springstatemachineexporter.analysis.service;
|
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.ReceiverFacts;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.flow.alloc.VirtualAccessorConstResolver;
|
||||||
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;
|
||||||
@@ -34,6 +37,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
protected final AccessorResolver accessorResolver;
|
protected final AccessorResolver accessorResolver;
|
||||||
protected final FieldInitializerFinder fieldInitializerFinder;
|
protected final FieldInitializerFinder fieldInitializerFinder;
|
||||||
protected final PathBindingEvaluator pathBindingEvaluator;
|
protected final PathBindingEvaluator pathBindingEvaluator;
|
||||||
|
protected final AllocationFactPropagator allocationFactPropagator;
|
||||||
|
protected final VirtualAccessorConstResolver virtualAccessorConstResolver;
|
||||||
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<>();
|
||||||
@@ -75,6 +80,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
this.constantExtractor.setAccessorResolver(this.accessorResolver);
|
this.constantExtractor.setAccessorResolver(this.accessorResolver);
|
||||||
this.fieldInitializerFinder = new FieldInitializerFinder(context);
|
this.fieldInitializerFinder = new FieldInitializerFinder(context);
|
||||||
this.pathBindingEvaluator = new PathBindingEvaluator(context, variableTracer, constantResolver, typeResolver);
|
this.pathBindingEvaluator = new PathBindingEvaluator(context, variableTracer, constantResolver, typeResolver);
|
||||||
|
this.allocationFactPropagator = new AllocationFactPropagator(
|
||||||
|
context, variableTracer, constructorAnalyzer);
|
||||||
|
this.virtualAccessorConstResolver = new VirtualAccessorConstResolver(
|
||||||
|
context, accessorResolver, constantResolver);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
|
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
|
||||||
@@ -966,9 +975,12 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
if (declaredType != null) {
|
if (declaredType != null) {
|
||||||
TypeDeclaration currentTd = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
|
TypeDeclaration currentTd = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
|
||||||
CompilationUnit cu = currentTd != null && currentTd.getRoot() instanceof CompilationUnit ? (CompilationUnit) currentTd.getRoot() : null;
|
CompilationUnit cu = currentTd != null && currentTd.getRoot() instanceof CompilationUnit ? (CompilationUnit) currentTd.getRoot() : null;
|
||||||
List<String> values = resolveAccessorConstants(declaredType, methodName, cu);
|
// Defer CHA accessor union for virtual getType/getEvent — allocation gate runs next.
|
||||||
if (values != null && !values.isEmpty()) {
|
if (!shouldDeferVirtualAccessorChaFill(methodName, path, callGraph)) {
|
||||||
polymorphicEvents.addAll(values);
|
List<String> values = resolveAccessorConstants(declaredType, methodName, cu);
|
||||||
|
if (values != null && !values.isEmpty()) {
|
||||||
|
polymorphicEvents.addAll(values);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (polymorphicEvents.isEmpty() && variableTracer != null) {
|
if (polymorphicEvents.isEmpty() && variableTracer != null) {
|
||||||
String initializer = variableTracer.traceLocalVariable(methodFqn, varName);
|
String initializer = variableTracer.traceLocalVariable(methodFqn, varName);
|
||||||
@@ -1002,9 +1014,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
TypeDeclaration currentTd = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
|
TypeDeclaration currentTd = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
|
||||||
if (currentTd != null && context.findMethodDeclaration(currentTd, methodName, true) != null) {
|
if (currentTd != null && context.findMethodDeclaration(currentTd, methodName, true) != null) {
|
||||||
CompilationUnit cu = currentTd.getRoot() instanceof CompilationUnit ? (CompilationUnit) currentTd.getRoot() : null;
|
CompilationUnit cu = currentTd.getRoot() instanceof CompilationUnit ? (CompilationUnit) currentTd.getRoot() : null;
|
||||||
List<String> values = resolveAccessorConstants(context.getFqn(currentTd), methodName, cu);
|
if (!shouldDeferVirtualAccessorChaFill(methodName, path, callGraph)) {
|
||||||
if (values != null && !values.isEmpty()) {
|
List<String> values = resolveAccessorConstants(context.getFqn(currentTd), methodName, cu);
|
||||||
polymorphicEvents.addAll(values);
|
if (values != null && !values.isEmpty()) {
|
||||||
|
polymorphicEvents.addAll(values);
|
||||||
|
sourceMethod = methodFqn;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
sourceMethod = methodFqn;
|
sourceMethod = methodFqn;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1018,6 +1035,15 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
if (declaredType != null) {
|
if (declaredType != null) {
|
||||||
List<String> enumValues = context.getEnumValues(declaredType);
|
List<String> enumValues = context.getEnumValues(declaredType);
|
||||||
if (enumValues != null && !enumValues.isEmpty()) {
|
if (enumValues != null && !enumValues.isEmpty()) {
|
||||||
|
// Prefer allocation proof over any prior CHA/accessor fill (clear+replace on success).
|
||||||
|
if (tryResolveAllocationSensitiveAccessor(
|
||||||
|
exprNode,
|
||||||
|
methodName,
|
||||||
|
path,
|
||||||
|
callGraph,
|
||||||
|
polymorphicEvents)) {
|
||||||
|
isAmbiguous = polymorphicEvents.size() > 1;
|
||||||
|
}
|
||||||
if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context)
|
if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context)
|
||||||
&& polymorphicEvents.isEmpty()
|
&& polymorphicEvents.isEmpty()
|
||||||
&& exprNode instanceof MethodInvocation miArg
|
&& exprNode instanceof MethodInvocation miArg
|
||||||
@@ -1041,6 +1067,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
&& exprNode instanceof Expression expressionArg) {
|
&& exprNode instanceof Expression expressionArg) {
|
||||||
addConstantsFromTracedExpression(expressionArg, polymorphicEvents, path, callGraph, entryMethod);
|
addConstantsFromTracedExpression(expressionArg, polymorphicEvents, path, callGraph, entryMethod);
|
||||||
}
|
}
|
||||||
|
if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context)
|
||||||
|
&& polymorphicEvents.isEmpty()
|
||||||
|
&& tryRecoverAllocationSensitiveInto(path, callGraph, polymorphicEvents)) {
|
||||||
|
isAmbiguous = false;
|
||||||
|
}
|
||||||
if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context)
|
if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context)
|
||||||
&& !tp.isExternal()
|
&& !tp.isExternal()
|
||||||
&& !isRuntimeEnumParameter(exprNode instanceof Expression expression ? expression : null)
|
&& !isRuntimeEnumParameter(exprNode instanceof Expression expression ? expression : null)
|
||||||
@@ -1052,6 +1083,16 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
isAmbiguous = true;
|
isAmbiguous = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Allocation-sensitive virtual accessor: if the receiver is a known CIC
|
||||||
|
// (possibly forwarded through a param), resolve only that override's constant.
|
||||||
|
if (tryResolveAllocationSensitiveAccessor(
|
||||||
|
exprNode,
|
||||||
|
methodName,
|
||||||
|
path,
|
||||||
|
callGraph,
|
||||||
|
polymorphicEvents)) {
|
||||||
|
isAmbiguous = polymorphicEvents.size() > 1;
|
||||||
|
} else {
|
||||||
List<String> typesToInspect = new ArrayList<>();
|
List<String> typesToInspect = new ArrayList<>();
|
||||||
if ("inline-instantiation".equals(sourceMethod)) {
|
if ("inline-instantiation".equals(sourceMethod)) {
|
||||||
typesToInspect.add(declaredType);
|
typesToInspect.add(declaredType);
|
||||||
@@ -1094,6 +1135,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
}
|
}
|
||||||
isAmbiguous = true;
|
isAmbiguous = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1233,6 +1275,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
|
|
||||||
polymorphicEvents = narrowPolymorphicEventsWithPathBindings(polymorphicEvents, path, callGraph, tp);
|
polymorphicEvents = narrowPolymorphicEventsWithPathBindings(polymorphicEvents, path, callGraph, tp);
|
||||||
|
|
||||||
|
// sendEvent arg may already be <SYMBOLIC:Enum.*> / full enum widen while the dispatcher
|
||||||
|
// frame still has valueOf(wrapper.getEvent().getType()) with a proven CIC — narrow here.
|
||||||
|
if (polymorphicEvents.size() != 1
|
||||||
|
&& tryRecoverAllocationSensitiveInto(path, callGraph, polymorphicEvents)) {
|
||||||
|
isAmbiguous = false;
|
||||||
|
}
|
||||||
|
|
||||||
Expression runtimeExpr = exprNode instanceof Expression expression ? expression : null;
|
Expression runtimeExpr = exprNode instanceof Expression expression ? expression : null;
|
||||||
boolean hasConcretePolymorphic = polymorphicEvents.stream()
|
boolean hasConcretePolymorphic = polymorphicEvents.stream()
|
||||||
.anyMatch(pe -> pe != null && !pe.startsWith("<SYMBOLIC:"));
|
.anyMatch(pe -> pe != null && !pe.startsWith("<SYMBOLIC:"));
|
||||||
@@ -1282,6 +1331,15 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
polymorphicEvents.add(click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType()));
|
polymorphicEvents.add(click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType()));
|
||||||
return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, polymorphicEvents);
|
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);
|
||||||
|
if (recovered.size() == 1) {
|
||||||
|
return buildTriggerPointWithPolymorphicEvents(
|
||||||
|
tp,
|
||||||
|
"valueOf(alloc.getType())",
|
||||||
|
recovered);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tp;
|
return tp;
|
||||||
@@ -1629,6 +1687,211 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
ResolutionBudget.defaults());
|
ResolutionBudget.defaults());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* @return true when a unique constant was proven and written into {@code polymorphicEvents}
|
||||||
|
*/
|
||||||
|
private boolean tryResolveAllocationSensitiveAccessor(
|
||||||
|
Object exprNode,
|
||||||
|
String methodName,
|
||||||
|
List<String> path,
|
||||||
|
Map<String, List<CallEdge>> callGraph,
|
||||||
|
List<String> polymorphicEvents) {
|
||||||
|
if (!(exprNode instanceof Expression expression)
|
||||||
|
|| allocationFactPropagator == null
|
||||||
|
|| virtualAccessorConstResolver == 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);
|
||||||
|
if (unique.size() != 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
polymorphicEvents.clear();
|
||||||
|
polymorphicEvents.add(unique.get(0));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefer the named accessor on the expression itself; for {@code Enum.valueOf(e.getType())}
|
||||||
|
* dig into the string argument.
|
||||||
|
*/
|
||||||
|
private static MethodInvocation findVirtualAccessorInvocation(Expression expression, String preferredName) {
|
||||||
|
Expression peeled = expression;
|
||||||
|
while (peeled instanceof ParenthesizedExpression pe) {
|
||||||
|
peeled = pe.getExpression();
|
||||||
|
}
|
||||||
|
if (peeled instanceof MethodInvocation mi) {
|
||||||
|
String name = mi.getName().getIdentifier();
|
||||||
|
if (preferredName != null && preferredName.equals(name) && isVirtualAccessorName(name)) {
|
||||||
|
return mi;
|
||||||
|
}
|
||||||
|
if ("valueOf".equals(name) && mi.arguments().size() == 1
|
||||||
|
&& mi.arguments().get(0) instanceof Expression arg) {
|
||||||
|
MethodInvocation nested = findVirtualAccessorInvocation(arg, null);
|
||||||
|
if (nested != null) {
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (preferredName == null && isVirtualAccessorName(name)) {
|
||||||
|
return mi;
|
||||||
|
}
|
||||||
|
if (isVirtualAccessorName(name)) {
|
||||||
|
return mi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isVirtualAccessorName(String name) {
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
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) {
|
||||||
|
return "getType".equals(name)
|
||||||
|
|| "getEvent".equals(name)
|
||||||
|
|| "getId".equals(name)
|
||||||
|
|| "resolveEventCode".equals(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip early CHA {@code resolveAccessorConstants} for virtual event accessors when a call path
|
||||||
|
* exists — allocation-sensitive resolve runs next and must not start from a polluted multi-set.
|
||||||
|
*/
|
||||||
|
private boolean shouldDeferVirtualAccessorChaFill(
|
||||||
|
String methodName,
|
||||||
|
List<String> path,
|
||||||
|
Map<String, List<CallEdge>> callGraph) {
|
||||||
|
if (!isVirtualAccessorName(methodName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (path == null || path.size() < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return callGraph != null || this.graph != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path-scoped recovery into {@code polymorphicEvents} when sendEvent's argument lost the
|
||||||
|
* {@code valueOf(receiver.getType())} AST (collapsed to symbolic / enum widen).
|
||||||
|
*
|
||||||
|
* @return true when a unique allocation-sensitive constant was proven
|
||||||
|
*/
|
||||||
|
private boolean tryRecoverAllocationSensitiveInto(
|
||||||
|
List<String> path,
|
||||||
|
Map<String, List<CallEdge>> callGraph,
|
||||||
|
List<String> polymorphicEvents) {
|
||||||
|
List<String> recovered = recoverAllocationSensitiveAccessorFromPath(path, callGraph);
|
||||||
|
if (recovered.size() != 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
polymorphicEvents.clear();
|
||||||
|
polymorphicEvents.add(recovered.get(0));
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.flow.alloc;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.service.VariableTracer;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
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 java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class AllocationSensitiveGetTypeTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sameMethodCicReceiverResolvesUniqueGetTypeConstant() throws Exception {
|
||||||
|
writeBaseFixture();
|
||||||
|
Files.writeString(tempDir.resolve("Use.java"), """
|
||||||
|
package com.example;
|
||||||
|
class LocalUse {
|
||||||
|
void run() {
|
||||||
|
RichEvent e = new PayRichEvent();
|
||||||
|
String t = e.getType();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
CodebaseContext context = scan();
|
||||||
|
AllocationFactPropagator propagator = propagator(context);
|
||||||
|
VirtualAccessorConstResolver resolver = resolver(context);
|
||||||
|
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration("com.example.LocalUse");
|
||||||
|
MethodDeclaration run = context.findMethodDeclaration(td, "run", true);
|
||||||
|
MethodInvocation getType = findInvocation(run, "getType");
|
||||||
|
|
||||||
|
ReceiverFacts facts = propagator.factsForAccessorReceiver(getType, List.of(), Map.of());
|
||||||
|
assertThat(facts.isUnique()).isTrue();
|
||||||
|
assertThat(resolver.resolveUniqueConstant(facts, "getType"))
|
||||||
|
.containsExactly("PAY");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oneHopParamForwardResolvesUniqueGetTypeNameConstant() throws Exception {
|
||||||
|
writeBaseFixture();
|
||||||
|
Files.writeString(tempDir.resolve("Dispatch.java"), """
|
||||||
|
package com.example;
|
||||||
|
class Api {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
void pay() { dispatcher.fire(new PayRichEvent()); }
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
void fire(RichEvent e) {
|
||||||
|
String t = e.getType();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
CodebaseContext context = scan();
|
||||||
|
AllocationFactPropagator propagator = propagator(context);
|
||||||
|
VirtualAccessorConstResolver resolver = resolver(context);
|
||||||
|
|
||||||
|
TypeDeclaration dispatcher = context.getTypeDeclaration("com.example.Dispatcher");
|
||||||
|
MethodDeclaration fire = context.findMethodDeclaration(dispatcher, "fire", true);
|
||||||
|
MethodInvocation getType = findInvocation(fire, "getType");
|
||||||
|
|
||||||
|
Map<String, List<CallEdge>> graph = new HashMap<>();
|
||||||
|
graph.put("com.example.Api.pay", List.of(
|
||||||
|
new CallEdge("com.example.Dispatcher.fire", List.of("new PayRichEvent()"))));
|
||||||
|
List<String> path = List.of("com.example.Api.pay", "com.example.Dispatcher.fire");
|
||||||
|
|
||||||
|
ReceiverFacts facts = propagator.factsForAccessorReceiver(getType, path, graph);
|
||||||
|
assertThat(facts.isUnique()).isTrue();
|
||||||
|
assertThat(resolver.resolveUniqueConstant(facts, "getType"))
|
||||||
|
.containsExactly("PAY");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void multiImplWithoutAllocationStaysEmpty() throws Exception {
|
||||||
|
writeBaseFixture();
|
||||||
|
Files.writeString(tempDir.resolve("Shared.java"), """
|
||||||
|
package com.example;
|
||||||
|
class SharedDispatcher {
|
||||||
|
RichEvent carrier;
|
||||||
|
void fire() {
|
||||||
|
String t = carrier.getType();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
CodebaseContext context = scan();
|
||||||
|
AllocationFactPropagator propagator = propagator(context);
|
||||||
|
VirtualAccessorConstResolver resolver = resolver(context);
|
||||||
|
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration("com.example.SharedDispatcher");
|
||||||
|
MethodDeclaration fire = context.findMethodDeclaration(td, "fire", true);
|
||||||
|
MethodInvocation getType = findInvocation(fire, "getType");
|
||||||
|
|
||||||
|
ReceiverFacts facts = propagator.factsForAccessorReceiver(getType, List.of(), Map.of());
|
||||||
|
assertThat(facts.isEmpty()).isTrue();
|
||||||
|
assertThat(resolver.resolveUniqueConstant(facts, "getType")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void factoryReturnResolvesUniqueGetTypeConstant() throws Exception {
|
||||||
|
writeBaseFixture();
|
||||||
|
Files.writeString(tempDir.resolve("Factory.java"), """
|
||||||
|
package com.example;
|
||||||
|
class Events {
|
||||||
|
static RichEvent pay() { return new PayRichEvent(); }
|
||||||
|
}
|
||||||
|
class Api {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
void pay() { dispatcher.fire(Events.pay()); }
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
void fire(RichEvent e) {
|
||||||
|
String t = e.getType();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
CodebaseContext context = scan();
|
||||||
|
AllocationFactPropagator propagator = propagator(context);
|
||||||
|
VirtualAccessorConstResolver resolver = resolver(context);
|
||||||
|
|
||||||
|
TypeDeclaration dispatcher = context.getTypeDeclaration("com.example.Dispatcher");
|
||||||
|
MethodDeclaration fire = context.findMethodDeclaration(dispatcher, "fire", true);
|
||||||
|
MethodInvocation getType = findInvocation(fire, "getType");
|
||||||
|
|
||||||
|
Map<String, List<CallEdge>> graph = new HashMap<>();
|
||||||
|
graph.put("com.example.Api.pay", List.of(
|
||||||
|
new CallEdge("com.example.Dispatcher.fire", List.of("Events.pay()"))));
|
||||||
|
List<String> path = List.of("com.example.Api.pay", "com.example.Dispatcher.fire");
|
||||||
|
|
||||||
|
ReceiverFacts facts = propagator.factsForAccessorReceiver(getType, path, graph);
|
||||||
|
assertThat(facts.isUnique()).isTrue();
|
||||||
|
assertThat(resolver.resolveUniqueConstant(facts, "getType"))
|
||||||
|
.containsExactly("PAY");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wrapperGetterPeelResolvesUniqueGetTypeConstant() throws Exception {
|
||||||
|
writeBaseFixture();
|
||||||
|
Files.writeString(tempDir.resolve("Wrapper.java"), """
|
||||||
|
package com.example;
|
||||||
|
class EventWrapper {
|
||||||
|
private final RichEvent event;
|
||||||
|
EventWrapper(RichEvent event) { this.event = event; }
|
||||||
|
RichEvent getEvent() { return event; }
|
||||||
|
}
|
||||||
|
class Api {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
void pay() { dispatcher.fire(new EventWrapper(new PayRichEvent())); }
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
void fire(EventWrapper w) {
|
||||||
|
String t = w.getEvent().getType();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
CodebaseContext context = scan();
|
||||||
|
AllocationFactPropagator propagator = propagator(context);
|
||||||
|
VirtualAccessorConstResolver resolver = resolver(context);
|
||||||
|
|
||||||
|
TypeDeclaration dispatcher = context.getTypeDeclaration("com.example.Dispatcher");
|
||||||
|
MethodDeclaration fire = context.findMethodDeclaration(dispatcher, "fire", true);
|
||||||
|
MethodInvocation getType = findInvocation(fire, "getType");
|
||||||
|
|
||||||
|
Map<String, List<CallEdge>> graph = new HashMap<>();
|
||||||
|
graph.put("com.example.Api.pay", List.of(
|
||||||
|
new CallEdge("com.example.Dispatcher.fire",
|
||||||
|
List.of("new EventWrapper(new PayRichEvent())"))));
|
||||||
|
List<String> path = List.of("com.example.Api.pay", "com.example.Dispatcher.fire");
|
||||||
|
|
||||||
|
ReceiverFacts facts = propagator.factsForAccessorReceiver(getType, path, graph);
|
||||||
|
assertThat(facts.isUnique()).isTrue();
|
||||||
|
assertThat(resolver.resolveUniqueConstant(facts, "getType"))
|
||||||
|
.containsExactly("PAY");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nonConstantAccessorBodyStaysEmpty() throws Exception {
|
||||||
|
writeBaseFixture();
|
||||||
|
Files.writeString(tempDir.resolve("Mystery.java"), """
|
||||||
|
package com.example;
|
||||||
|
class MysteryRichEvent implements RichEvent {
|
||||||
|
public String getType() {
|
||||||
|
return Math.random() > 0.5 ? OrderEvent.PAY.name() : OrderEvent.SHIP.name();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class MysteryUse {
|
||||||
|
void run() {
|
||||||
|
RichEvent e = new MysteryRichEvent();
|
||||||
|
String t = e.getType();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
CodebaseContext context = scan();
|
||||||
|
AllocationFactPropagator propagator = propagator(context);
|
||||||
|
VirtualAccessorConstResolver resolver = resolver(context);
|
||||||
|
|
||||||
|
TypeDeclaration td = context.getTypeDeclaration("com.example.MysteryUse");
|
||||||
|
MethodDeclaration run = context.findMethodDeclaration(td, "run", true);
|
||||||
|
MethodInvocation getType = findInvocation(run, "getType");
|
||||||
|
|
||||||
|
ReceiverFacts facts = propagator.factsForAccessorReceiver(getType, List.of(), Map.of());
|
||||||
|
assertThat(facts.isUnique()).isTrue();
|
||||||
|
assertThat(resolver.resolveUniqueConstant(facts, "getType")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeBaseFixture() throws Exception {
|
||||||
|
Files.writeString(tempDir.resolve("Base.java"), """
|
||||||
|
package com.example;
|
||||||
|
interface RichEvent { String getType(); }
|
||||||
|
class PayRichEvent implements RichEvent {
|
||||||
|
public String getType() { return OrderEvent.PAY.name(); }
|
||||||
|
}
|
||||||
|
class ShipRichEvent implements RichEvent {
|
||||||
|
public String getType() { return OrderEvent.SHIP.name(); }
|
||||||
|
}
|
||||||
|
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private CodebaseContext scan() throws Exception {
|
||||||
|
CodebaseContext context = new CodebaseContext();
|
||||||
|
context.setResolveBindings(true);
|
||||||
|
context.scan(tempDir);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AllocationFactPropagator propagator(CodebaseContext context) {
|
||||||
|
ConstantResolver constants = context.getConstantResolver();
|
||||||
|
VariableTracer tracer = new VariableTracer(context, constants);
|
||||||
|
ConstructorAnalyzer ctor = new ConstructorAnalyzer(context, constants);
|
||||||
|
return new AllocationFactPropagator(context, tracer, ctor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VirtualAccessorConstResolver resolver(CodebaseContext context) {
|
||||||
|
ConstantResolver constants = context.getConstantResolver();
|
||||||
|
ConstantExtractor extractor = new ConstantExtractor(context, constants, mi -> null);
|
||||||
|
ConstructorAnalyzer ctor = new ConstructorAnalyzer(context, constants);
|
||||||
|
AccessorResolver accessors = new AccessorResolver(context, extractor, ctor, constants);
|
||||||
|
return new VirtualAccessorConstResolver(context, accessors, constants);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MethodInvocation findInvocation(MethodDeclaration method, String name) {
|
||||||
|
MethodInvocation[] found = {null};
|
||||||
|
method.accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
|
||||||
|
@Override
|
||||||
|
public boolean visit(MethodInvocation node) {
|
||||||
|
if (name.equals(node.getName().getIdentifier())) {
|
||||||
|
found[0] = node;
|
||||||
|
}
|
||||||
|
return super.visit(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertThat(found[0]).as("expected invocation %s", name).isNotNull();
|
||||||
|
return found[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.service;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import click.kamil.springstatemachineexporter.model.Transition;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broader coverage for allocation-sensitive {@code getType()} beyond the happy-path fixture.
|
||||||
|
*/
|
||||||
|
class AllocationSensitiveEdgeCasesPipelineTest {
|
||||||
|
|
||||||
|
private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig";
|
||||||
|
private static final String EVENT_TYPE = "com.example.OrderEvent";
|
||||||
|
private static final String STATE_TYPE = "com.example.OrderState";
|
||||||
|
|
||||||
|
private static final String COMMON_TAIL = """
|
||||||
|
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||||
|
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||||
|
enum OrderState { NEW, PAID, SHIPPED, CANCELLED }
|
||||||
|
class OrderStateMachineConfig {}
|
||||||
|
""";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void genericRichEventCarrierWithNamePeel(@TempDir Path tempDir) throws Exception {
|
||||||
|
String source = """
|
||||||
|
package com.example;
|
||||||
|
public class ApiController {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
public void pay() { dispatcher.fire(new PayRichEvent()); }
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
StateMachine machine;
|
||||||
|
void fire(RichEvent<?> e) {
|
||||||
|
machine.sendEvent(OrderEvent.valueOf(e.getType()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface RichEvent<T> { String getType(); }
|
||||||
|
class PayRichEvent implements RichEvent<OrderEvent> {
|
||||||
|
public String getType() { return OrderEvent.PAY.name(); }
|
||||||
|
}
|
||||||
|
class ShipRichEvent implements RichEvent<OrderEvent> {
|
||||||
|
public String getType() { return OrderEvent.SHIP.name(); }
|
||||||
|
}
|
||||||
|
""" + COMMON_TAIL;
|
||||||
|
assertResolvedPay(source, tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void localVarThenPassThroughParam(@TempDir Path tempDir) throws Exception {
|
||||||
|
String source = """
|
||||||
|
package com.example;
|
||||||
|
public class ApiController {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
public void pay() {
|
||||||
|
RichEvent e = new PayRichEvent();
|
||||||
|
dispatcher.fire(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
StateMachine machine;
|
||||||
|
void fire(RichEvent e) {
|
||||||
|
machine.sendEvent(OrderEvent.valueOf(e.getType()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface RichEvent { String getType(); }
|
||||||
|
class PayRichEvent implements RichEvent {
|
||||||
|
public String getType() { return OrderEvent.PAY.name(); }
|
||||||
|
}
|
||||||
|
class ShipRichEvent implements RichEvent {
|
||||||
|
public String getType() { return OrderEvent.SHIP.name(); }
|
||||||
|
}
|
||||||
|
""" + COMMON_TAIL;
|
||||||
|
assertResolvedPay(source, tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void twoHopParamForward(@TempDir Path tempDir) throws Exception {
|
||||||
|
String source = """
|
||||||
|
package com.example;
|
||||||
|
public class ApiController {
|
||||||
|
Facade facade;
|
||||||
|
public void pay() { facade.accept(new PayRichEvent()); }
|
||||||
|
}
|
||||||
|
class Facade {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
void accept(RichEvent e) { dispatcher.fire(e); }
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
StateMachine machine;
|
||||||
|
void fire(RichEvent e) {
|
||||||
|
machine.sendEvent(OrderEvent.valueOf(e.getType()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface RichEvent { String getType(); }
|
||||||
|
class PayRichEvent implements RichEvent {
|
||||||
|
public String getType() { return OrderEvent.PAY.name(); }
|
||||||
|
}
|
||||||
|
class ShipRichEvent implements RichEvent {
|
||||||
|
public String getType() { return OrderEvent.SHIP.name(); }
|
||||||
|
}
|
||||||
|
""" + COMMON_TAIL;
|
||||||
|
assertResolvedPay(source, tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void enumReturningGetTypeWithoutValueOf(@TempDir Path tempDir) throws Exception {
|
||||||
|
String source = """
|
||||||
|
package com.example;
|
||||||
|
public class ApiController {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
public void pay() { dispatcher.fire(new PayRichEvent()); }
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
StateMachine machine;
|
||||||
|
void fire(RichEvent e) {
|
||||||
|
machine.sendEvent(e.getType());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface RichEvent { OrderEvent getType(); }
|
||||||
|
class PayRichEvent implements RichEvent {
|
||||||
|
public OrderEvent getType() { return OrderEvent.PAY; }
|
||||||
|
}
|
||||||
|
class ShipRichEvent implements RichEvent {
|
||||||
|
public OrderEvent getType() { return OrderEvent.SHIP; }
|
||||||
|
}
|
||||||
|
""" + COMMON_TAIL;
|
||||||
|
assertResolvedPay(source, tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getEventAliasAccessor(@TempDir Path tempDir) throws Exception {
|
||||||
|
String source = """
|
||||||
|
package com.example;
|
||||||
|
public class ApiController {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
public void pay() { dispatcher.fire(new PayRichEvent()); }
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
StateMachine machine;
|
||||||
|
void fire(RichEvent e) {
|
||||||
|
machine.sendEvent(e.getEvent());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface RichEvent { OrderEvent getEvent(); }
|
||||||
|
class PayRichEvent implements RichEvent {
|
||||||
|
public OrderEvent getEvent() { return OrderEvent.PAY; }
|
||||||
|
}
|
||||||
|
class ShipRichEvent implements RichEvent {
|
||||||
|
public OrderEvent getEvent() { return OrderEvent.SHIP; }
|
||||||
|
}
|
||||||
|
""" + COMMON_TAIL;
|
||||||
|
assertResolvedPay(source, tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void abstractBaseGetTypeOverride(@TempDir Path tempDir) throws Exception {
|
||||||
|
String source = """
|
||||||
|
package com.example;
|
||||||
|
public class ApiController {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
public void pay() { dispatcher.fire(new PayRichEvent()); }
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
StateMachine machine;
|
||||||
|
void fire(BaseEvent e) {
|
||||||
|
machine.sendEvent(OrderEvent.valueOf(e.getType()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
abstract class BaseEvent { abstract String getType(); }
|
||||||
|
class PayRichEvent extends BaseEvent {
|
||||||
|
String getType() { return OrderEvent.PAY.name(); }
|
||||||
|
}
|
||||||
|
class ShipRichEvent extends BaseEvent {
|
||||||
|
String getType() { return OrderEvent.SHIP.name(); }
|
||||||
|
}
|
||||||
|
""" + COMMON_TAIL;
|
||||||
|
assertResolvedPay(source, tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory return {@code Events.pay() → new PayRichEvent()} must propagate through the
|
||||||
|
* interface param to a unique {@code getType()} constant.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void factoryMethodReturningConcreteEvent(@TempDir Path tempDir) throws Exception {
|
||||||
|
String source = """
|
||||||
|
package com.example;
|
||||||
|
public class ApiController {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
public void pay() { dispatcher.fire(Events.pay()); }
|
||||||
|
}
|
||||||
|
class Events {
|
||||||
|
static RichEvent pay() { return new PayRichEvent(); }
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
StateMachine machine;
|
||||||
|
void fire(RichEvent e) {
|
||||||
|
machine.sendEvent(OrderEvent.valueOf(e.getType()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface RichEvent { String getType(); }
|
||||||
|
class PayRichEvent implements RichEvent {
|
||||||
|
public String getType() { return OrderEvent.PAY.name(); }
|
||||||
|
}
|
||||||
|
class ShipRichEvent implements RichEvent {
|
||||||
|
public String getType() { return OrderEvent.SHIP.name(); }
|
||||||
|
}
|
||||||
|
""" + COMMON_TAIL;
|
||||||
|
assertResolvedPay(source, tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper getter peel: {@code new EventWrapper(new PayRichEvent()).getEvent().getType()}.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void wrapperGetterChainCarrier(@TempDir Path tempDir) throws Exception {
|
||||||
|
String source = """
|
||||||
|
package com.example;
|
||||||
|
public class ApiController {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
public void pay() { dispatcher.fire(new EventWrapper(new PayRichEvent())); }
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
StateMachine machine;
|
||||||
|
void fire(EventWrapper w) {
|
||||||
|
machine.sendEvent(OrderEvent.valueOf(w.getEvent().getType()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class EventWrapper {
|
||||||
|
private final RichEvent event;
|
||||||
|
EventWrapper(RichEvent event) { this.event = event; }
|
||||||
|
RichEvent getEvent() { return event; }
|
||||||
|
}
|
||||||
|
interface RichEvent { String getType(); }
|
||||||
|
class PayRichEvent implements RichEvent {
|
||||||
|
public String getType() { return OrderEvent.PAY.name(); }
|
||||||
|
}
|
||||||
|
class ShipRichEvent implements RichEvent {
|
||||||
|
public String getType() { return OrderEvent.SHIP.name(); }
|
||||||
|
}
|
||||||
|
""" + COMMON_TAIL;
|
||||||
|
assertResolvedPay(source, tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mysteryRuntimeBranchStaysFailClosed(@TempDir Path tempDir) throws Exception {
|
||||||
|
String source = """
|
||||||
|
package com.example;
|
||||||
|
public class ApiController {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
public void mystery() { dispatcher.fire(new MysteryRichEvent()); }
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
StateMachine machine;
|
||||||
|
void fire(RichEvent e) {
|
||||||
|
machine.sendEvent(OrderEvent.valueOf(e.getType()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface RichEvent { String getType(); }
|
||||||
|
class MysteryRichEvent implements RichEvent {
|
||||||
|
public String getType() {
|
||||||
|
return Math.random() > 0.5 ? OrderEvent.PAY.name() : OrderEvent.SHIP.name();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class PayRichEvent implements RichEvent {
|
||||||
|
public String getType() { return OrderEvent.PAY.name(); }
|
||||||
|
}
|
||||||
|
""" + COMMON_TAIL;
|
||||||
|
assertFailClosed(source, "mystery", tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertResolvedPay(String source, Path tempDir) throws Exception {
|
||||||
|
CodebaseContext context = scanSource(source, tempDir);
|
||||||
|
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
|
||||||
|
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
|
||||||
|
Transition cancel = transition("NEW", "CANCELLED", EVENT_TYPE + ".CANCEL");
|
||||||
|
CallChain linked = resolveLinkAndAssertSingleMatch(
|
||||||
|
context,
|
||||||
|
"com.example.ApiController",
|
||||||
|
"pay",
|
||||||
|
"com.example.StateMachine",
|
||||||
|
"sendEvent",
|
||||||
|
MACHINE_CONFIG,
|
||||||
|
EVENT_TYPE,
|
||||||
|
STATE_TYPE,
|
||||||
|
EVENT_TYPE + ".PAY",
|
||||||
|
EngineKind.JDT,
|
||||||
|
pay,
|
||||||
|
ship,
|
||||||
|
cancel);
|
||||||
|
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
|
||||||
|
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
|
||||||
|
.filteredOn(pe -> pe != null && (pe.contains("PAY") || pe.contains("SHIP") || pe.contains("CANCEL")))
|
||||||
|
.allMatch(pe -> pe.contains("PAY"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertFailClosed(String source, String entryMethod, Path tempDir) throws Exception {
|
||||||
|
CodebaseContext context = scanSource(source, tempDir);
|
||||||
|
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
|
||||||
|
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
|
||||||
|
CallChain raw = resolveChain(context, "com.example.ApiController", entryMethod,
|
||||||
|
"com.example.StateMachine", "sendEvent", EngineKind.JDT);
|
||||||
|
CallChain linked = linkChain(context, raw, MACHINE_CONFIG, EVENT_TYPE, STATE_TYPE, pay, ship);
|
||||||
|
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
|
||||||
|
assertThat(linked.getLinkResolution())
|
||||||
|
.isIn(LinkResolution.AMBIGUOUS_WIDEN, LinkResolution.NO_MATCH, LinkResolution.UNRESOLVED_EXTERNAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.service;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import click.kamil.springstatemachineexporter.model.Transition;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Machine enums with ctor args ({@code PAY("pay", true)}) must still allocation-resolve
|
||||||
|
* when {@code getType()} returns the enum constant or {@code .name()}.
|
||||||
|
*/
|
||||||
|
class AllocationSensitiveEnumCtorPipelineTest {
|
||||||
|
|
||||||
|
private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig";
|
||||||
|
private static final String EVENT_TYPE = "com.example.OrderEvent";
|
||||||
|
private static final String STATE_TYPE = "com.example.OrderState";
|
||||||
|
|
||||||
|
private static final String ENUM_WITH_CTOR = """
|
||||||
|
enum OrderEvent {
|
||||||
|
PAY("pay", true), SHIP("ship", true), LOG("log", false), CANCEL("cancel", true);
|
||||||
|
private final String code;
|
||||||
|
private final boolean event;
|
||||||
|
OrderEvent(String code, boolean event) { this.code = code; this.event = event; }
|
||||||
|
public String getCode() { return code; }
|
||||||
|
public boolean isEvent() { return event; }
|
||||||
|
}
|
||||||
|
enum OrderState { NEW, PAID, SHIPPED, CANCELLED }
|
||||||
|
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||||
|
class OrderStateMachineConfig {}
|
||||||
|
""";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void namePeelWithEnumCtorArgs(@TempDir Path tempDir) throws Exception {
|
||||||
|
String source = """
|
||||||
|
package com.example;
|
||||||
|
public class ApiController {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
public void pay() { dispatcher.fire(new PayRichEvent()); }
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
StateMachine machine;
|
||||||
|
void fire(RichEvent e) {
|
||||||
|
machine.sendEvent(OrderEvent.valueOf(e.getType()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface RichEvent { String getType(); }
|
||||||
|
class PayRichEvent implements RichEvent {
|
||||||
|
public String getType() { return OrderEvent.PAY.name(); }
|
||||||
|
}
|
||||||
|
class ShipRichEvent implements RichEvent {
|
||||||
|
public String getType() { return OrderEvent.SHIP.name(); }
|
||||||
|
}
|
||||||
|
""" + ENUM_WITH_CTOR;
|
||||||
|
assertResolvedPay(source, tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void enumConstReturnWithCtorArgs(@TempDir Path tempDir) throws Exception {
|
||||||
|
String source = """
|
||||||
|
package com.example;
|
||||||
|
public class ApiController {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
public void pay() { dispatcher.fire(new PayRichEvent()); }
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
StateMachine machine;
|
||||||
|
void fire(RichEvent e) {
|
||||||
|
machine.sendEvent(e.getType());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface RichEvent { OrderEvent getType(); }
|
||||||
|
class PayRichEvent implements RichEvent {
|
||||||
|
public OrderEvent getType() { return OrderEvent.PAY; }
|
||||||
|
}
|
||||||
|
class ShipRichEvent implements RichEvent {
|
||||||
|
public OrderEvent getType() { return OrderEvent.SHIP; }
|
||||||
|
}
|
||||||
|
""" + ENUM_WITH_CTOR;
|
||||||
|
assertResolvedPay(source, tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getCodeFromEnumCtorFieldStaysFailClosed(@TempDir Path tempDir) throws Exception {
|
||||||
|
String source = """
|
||||||
|
package com.example;
|
||||||
|
public class ApiController {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
public void pay() { dispatcher.fire(new PayRichEvent()); }
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
StateMachine machine;
|
||||||
|
void fire(RichEvent e) {
|
||||||
|
machine.sendEvent(OrderEvent.valueOf(e.getType()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface RichEvent { String getType(); }
|
||||||
|
class PayRichEvent implements RichEvent {
|
||||||
|
public String getType() { return OrderEvent.PAY.getCode(); }
|
||||||
|
}
|
||||||
|
class ShipRichEvent implements RichEvent {
|
||||||
|
public String getType() { return OrderEvent.SHIP.getCode(); }
|
||||||
|
}
|
||||||
|
""" + ENUM_WITH_CTOR;
|
||||||
|
CodebaseContext context = scanSource(source, tempDir);
|
||||||
|
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
|
||||||
|
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
|
||||||
|
CallChain raw = resolveChain(context, "com.example.ApiController", "pay",
|
||||||
|
"com.example.StateMachine", "sendEvent", EngineKind.JDT);
|
||||||
|
CallChain linked = linkChain(context, raw, MACHINE_CONFIG, EVENT_TYPE, STATE_TYPE, pay, ship);
|
||||||
|
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
|
||||||
|
assertThat(linked.getLinkResolution())
|
||||||
|
.isIn(LinkResolution.AMBIGUOUS_WIDEN, LinkResolution.NO_MATCH, LinkResolution.UNRESOLVED_EXTERNAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertResolvedPay(String source, Path tempDir) throws Exception {
|
||||||
|
CodebaseContext context = scanSource(source, tempDir);
|
||||||
|
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
|
||||||
|
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
|
||||||
|
Transition cancel = transition("NEW", "CANCELLED", EVENT_TYPE + ".CANCEL");
|
||||||
|
CallChain linked = resolveLinkAndAssertSingleMatch(
|
||||||
|
context,
|
||||||
|
"com.example.ApiController",
|
||||||
|
"pay",
|
||||||
|
"com.example.StateMachine",
|
||||||
|
"sendEvent",
|
||||||
|
MACHINE_CONFIG,
|
||||||
|
EVENT_TYPE,
|
||||||
|
STATE_TYPE,
|
||||||
|
EVENT_TYPE + ".PAY",
|
||||||
|
EngineKind.JDT,
|
||||||
|
pay,
|
||||||
|
ship,
|
||||||
|
cancel);
|
||||||
|
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.service;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import click.kamil.springstatemachineexporter.model.Transition;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allocation-sensitive virtual {@code getType()} through a generic/interface carrier must
|
||||||
|
* propagate {@code Enum.CONST.name()} instead of unioning every implementor.
|
||||||
|
*/
|
||||||
|
class AllocationSensitiveRichEventPipelineTest {
|
||||||
|
|
||||||
|
private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig";
|
||||||
|
private static final String EVENT_TYPE = "com.example.OrderEvent";
|
||||||
|
private static final String STATE_TYPE = "com.example.OrderState";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void linkerShouldResolvePayWhenConcreteRichEventFlowsThroughInterfaceParam(@TempDir Path tempDir)
|
||||||
|
throws Exception {
|
||||||
|
String source = """
|
||||||
|
package com.example;
|
||||||
|
public class ApiController {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
public void pay() { dispatcher.fire(new PayRichEvent()); }
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
StateMachine machine;
|
||||||
|
void fire(RichEvent e) {
|
||||||
|
machine.sendEvent(OrderEvent.valueOf(e.getType()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface RichEvent { String getType(); }
|
||||||
|
class PayRichEvent implements RichEvent {
|
||||||
|
public String getType() { return OrderEvent.PAY.name(); }
|
||||||
|
}
|
||||||
|
class ShipRichEvent implements RichEvent {
|
||||||
|
public String getType() { 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 = scanSource(source, tempDir);
|
||||||
|
|
||||||
|
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
|
||||||
|
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
|
||||||
|
Transition cancel = transition("NEW", "CANCELLED", EVENT_TYPE + ".CANCEL");
|
||||||
|
|
||||||
|
CallChain linked = resolveLinkAndAssertSingleMatch(
|
||||||
|
context,
|
||||||
|
"com.example.ApiController",
|
||||||
|
"pay",
|
||||||
|
"com.example.StateMachine",
|
||||||
|
"sendEvent",
|
||||||
|
MACHINE_CONFIG,
|
||||||
|
EVENT_TYPE,
|
||||||
|
STATE_TYPE,
|
||||||
|
EVENT_TYPE + ".PAY",
|
||||||
|
EngineKind.JDT,
|
||||||
|
pay,
|
||||||
|
ship,
|
||||||
|
cancel);
|
||||||
|
|
||||||
|
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
|
||||||
|
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
|
||||||
|
.as("allocation-sensitive getType must not union ShipRichEvent")
|
||||||
|
.filteredOn(pe -> pe != null && (pe.contains("PAY") || pe.contains("SHIP") || pe.contains("CANCEL")))
|
||||||
|
.allMatch(pe -> pe.contains("PAY"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void linkerShouldStayFailClosedWhenRichEventFieldHasNoAllocation(@TempDir Path tempDir)
|
||||||
|
throws Exception {
|
||||||
|
String source = """
|
||||||
|
package com.example;
|
||||||
|
public class ApiController {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
public void dispatch() { dispatcher.fire(); }
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
StateMachine machine;
|
||||||
|
RichEvent carrier;
|
||||||
|
void fire() {
|
||||||
|
machine.sendEvent(OrderEvent.valueOf(carrier.getType()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface RichEvent { String getType(); }
|
||||||
|
class PayRichEvent implements RichEvent {
|
||||||
|
public String getType() { return OrderEvent.PAY.name(); }
|
||||||
|
}
|
||||||
|
class ShipRichEvent implements RichEvent {
|
||||||
|
public String getType() { 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 = scanSource(source, tempDir);
|
||||||
|
|
||||||
|
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
|
||||||
|
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
|
||||||
|
|
||||||
|
CallChain raw = resolveChain(context, "com.example.ApiController", "dispatch",
|
||||||
|
"com.example.StateMachine", "sendEvent", EngineKind.JDT);
|
||||||
|
|
||||||
|
CallChain linked = linkChain(
|
||||||
|
context,
|
||||||
|
raw,
|
||||||
|
MACHINE_CONFIG,
|
||||||
|
EVENT_TYPE,
|
||||||
|
STATE_TYPE,
|
||||||
|
pay,
|
||||||
|
ship);
|
||||||
|
|
||||||
|
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
|
||||||
|
assertThat(linked.getLinkResolution())
|
||||||
|
.isIn(LinkResolution.AMBIGUOUS_WIDEN, LinkResolution.NO_MATCH, LinkResolution.UNRESOLVED_EXTERNAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.service;
|
||||||
|
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||||
|
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
|
||||||
|
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
|
import click.kamil.springstatemachineexporter.model.Transition;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Robustness probes for allocation-sensitive getType — cases that historically bypassed
|
||||||
|
* or partially bypassed CIC→param narrowing.
|
||||||
|
*/
|
||||||
|
class AllocationSensitiveRobustnessPipelineTest {
|
||||||
|
|
||||||
|
private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig";
|
||||||
|
private static final String EVENT_TYPE = "com.example.OrderEvent";
|
||||||
|
private static final String STATE_TYPE = "com.example.OrderState";
|
||||||
|
|
||||||
|
private static final String TAIL = """
|
||||||
|
interface RichEvent { String getType(); }
|
||||||
|
class PayRichEvent implements RichEvent {
|
||||||
|
public String getType() { return OrderEvent.PAY.name(); }
|
||||||
|
}
|
||||||
|
class ShipRichEvent implements RichEvent {
|
||||||
|
public String getType() { return OrderEvent.SHIP.name(); }
|
||||||
|
}
|
||||||
|
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||||
|
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||||
|
enum OrderState { NEW, PAID, SHIPPED, CANCELLED }
|
||||||
|
class OrderStateMachineConfig {}
|
||||||
|
""";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void threeHopParamForward(@TempDir Path tempDir) throws Exception {
|
||||||
|
String source = """
|
||||||
|
package com.example;
|
||||||
|
public class ApiController {
|
||||||
|
Outer outer;
|
||||||
|
public void pay() { outer.accept(new PayRichEvent()); }
|
||||||
|
}
|
||||||
|
class Outer {
|
||||||
|
Mid mid;
|
||||||
|
void accept(RichEvent e) { mid.forward(e); }
|
||||||
|
}
|
||||||
|
class Mid {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
void forward(RichEvent e) { dispatcher.fire(e); }
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
StateMachine machine;
|
||||||
|
void fire(RichEvent e) {
|
||||||
|
machine.sendEvent(OrderEvent.valueOf(e.getType()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""" + TAIL;
|
||||||
|
assertResolvedPay(source, tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fieldAssignedFromParamThenGetType(@TempDir Path tempDir) throws Exception {
|
||||||
|
String source = """
|
||||||
|
package com.example;
|
||||||
|
public class ApiController {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
public void pay() { dispatcher.fire(new PayRichEvent()); }
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
StateMachine machine;
|
||||||
|
void fire(RichEvent e) {
|
||||||
|
RichEvent carrier = e;
|
||||||
|
machine.sendEvent(OrderEvent.valueOf(carrier.getType()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""" + TAIL;
|
||||||
|
assertResolvedPay(source, tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void castThroughInterface(@TempDir Path tempDir) throws Exception {
|
||||||
|
String source = """
|
||||||
|
package com.example;
|
||||||
|
public class ApiController {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
public void pay() { dispatcher.fire(new PayRichEvent()); }
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
StateMachine machine;
|
||||||
|
void fire(Object o) {
|
||||||
|
RichEvent e = (RichEvent) o;
|
||||||
|
machine.sendEvent(OrderEvent.valueOf(e.getType()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""" + TAIL;
|
||||||
|
assertResolvedPay(source, tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anonymousRichEventStaysFailClosed(@TempDir Path tempDir) throws Exception {
|
||||||
|
String source = """
|
||||||
|
package com.example;
|
||||||
|
public class ApiController {
|
||||||
|
Dispatcher dispatcher;
|
||||||
|
public void pay() {
|
||||||
|
dispatcher.fire(new RichEvent() {
|
||||||
|
public String getType() { return OrderEvent.PAY.name(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Dispatcher {
|
||||||
|
StateMachine machine;
|
||||||
|
void fire(RichEvent e) {
|
||||||
|
machine.sendEvent(OrderEvent.valueOf(e.getType()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""" + TAIL;
|
||||||
|
assertFailClosed(source, "pay", tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertResolvedPay(String source, Path tempDir) throws Exception {
|
||||||
|
CodebaseContext context = scanSource(source, tempDir);
|
||||||
|
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
|
||||||
|
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
|
||||||
|
Transition cancel = transition("NEW", "CANCELLED", EVENT_TYPE + ".CANCEL");
|
||||||
|
CallChain linked = resolveLinkAndAssertSingleMatch(
|
||||||
|
context,
|
||||||
|
"com.example.ApiController",
|
||||||
|
"pay",
|
||||||
|
"com.example.StateMachine",
|
||||||
|
"sendEvent",
|
||||||
|
MACHINE_CONFIG,
|
||||||
|
EVENT_TYPE,
|
||||||
|
STATE_TYPE,
|
||||||
|
EVENT_TYPE + ".PAY",
|
||||||
|
EngineKind.JDT,
|
||||||
|
pay,
|
||||||
|
ship,
|
||||||
|
cancel);
|
||||||
|
assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertFailClosed(String source, String entryMethod, Path tempDir) throws Exception {
|
||||||
|
CodebaseContext context = scanSource(source, tempDir);
|
||||||
|
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
|
||||||
|
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
|
||||||
|
CallChain raw = resolveChain(context, "com.example.ApiController", entryMethod,
|
||||||
|
"com.example.StateMachine", "sendEvent", EngineKind.JDT);
|
||||||
|
CallChain linked = linkChain(context, raw, MACHINE_CONFIG, EVENT_TYPE, STATE_TYPE, pay, ship);
|
||||||
|
assertThat(linked.getMatchedTransitions()).isNullOrEmpty();
|
||||||
|
assertThat(linked.getLinkResolution())
|
||||||
|
.isIn(LinkResolution.AMBIGUOUS_WIDEN, LinkResolution.NO_MATCH, LinkResolution.UNRESOLVED_EXTERNAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user