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;
|
||||
|
||||
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.pipeline.AccessorResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
|
||||
@@ -34,6 +37,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
protected final AccessorResolver accessorResolver;
|
||||
protected final FieldInitializerFinder fieldInitializerFinder;
|
||||
protected final PathBindingEvaluator pathBindingEvaluator;
|
||||
protected final AllocationFactPropagator allocationFactPropagator;
|
||||
protected final VirtualAccessorConstResolver virtualAccessorConstResolver;
|
||||
private final Map<String, ASTNode> parsedNodeCache = new HashMap<>();
|
||||
private final Map<String, List<String>> polymorphicCallCache = new HashMap<>();
|
||||
private final Map<String, Map<String, String>> pathBindingsCache = new HashMap<>();
|
||||
@@ -75,6 +80,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
this.constantExtractor.setAccessorResolver(this.accessorResolver);
|
||||
this.fieldInitializerFinder = new FieldInitializerFinder(context);
|
||||
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) {
|
||||
@@ -966,9 +975,12 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
if (declaredType != null) {
|
||||
TypeDeclaration currentTd = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
|
||||
CompilationUnit cu = currentTd != null && currentTd.getRoot() instanceof CompilationUnit ? (CompilationUnit) currentTd.getRoot() : null;
|
||||
List<String> values = resolveAccessorConstants(declaredType, methodName, cu);
|
||||
if (values != null && !values.isEmpty()) {
|
||||
polymorphicEvents.addAll(values);
|
||||
// Defer CHA accessor union for virtual getType/getEvent — allocation gate runs next.
|
||||
if (!shouldDeferVirtualAccessorChaFill(methodName, path, callGraph)) {
|
||||
List<String> values = resolveAccessorConstants(declaredType, methodName, cu);
|
||||
if (values != null && !values.isEmpty()) {
|
||||
polymorphicEvents.addAll(values);
|
||||
}
|
||||
}
|
||||
if (polymorphicEvents.isEmpty() && variableTracer != null) {
|
||||
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('.')));
|
||||
if (currentTd != null && context.findMethodDeclaration(currentTd, methodName, true) != null) {
|
||||
CompilationUnit cu = currentTd.getRoot() instanceof CompilationUnit ? (CompilationUnit) currentTd.getRoot() : null;
|
||||
List<String> values = resolveAccessorConstants(context.getFqn(currentTd), methodName, cu);
|
||||
if (values != null && !values.isEmpty()) {
|
||||
polymorphicEvents.addAll(values);
|
||||
if (!shouldDeferVirtualAccessorChaFill(methodName, path, callGraph)) {
|
||||
List<String> values = resolveAccessorConstants(context.getFqn(currentTd), methodName, cu);
|
||||
if (values != null && !values.isEmpty()) {
|
||||
polymorphicEvents.addAll(values);
|
||||
sourceMethod = methodFqn;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
sourceMethod = methodFqn;
|
||||
break;
|
||||
}
|
||||
@@ -1018,6 +1035,15 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
if (declaredType != null) {
|
||||
List<String> enumValues = context.getEnumValues(declaredType);
|
||||
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)
|
||||
&& polymorphicEvents.isEmpty()
|
||||
&& exprNode instanceof MethodInvocation miArg
|
||||
@@ -1041,6 +1067,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
&& exprNode instanceof Expression expressionArg) {
|
||||
addConstantsFromTracedExpression(expressionArg, polymorphicEvents, path, callGraph, entryMethod);
|
||||
}
|
||||
if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context)
|
||||
&& polymorphicEvents.isEmpty()
|
||||
&& tryRecoverAllocationSensitiveInto(path, callGraph, polymorphicEvents)) {
|
||||
isAmbiguous = false;
|
||||
}
|
||||
if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context)
|
||||
&& !tp.isExternal()
|
||||
&& !isRuntimeEnumParameter(exprNode instanceof Expression expression ? expression : null)
|
||||
@@ -1052,6 +1083,16 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
isAmbiguous = true;
|
||||
}
|
||||
} 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<>();
|
||||
if ("inline-instantiation".equals(sourceMethod)) {
|
||||
typesToInspect.add(declaredType);
|
||||
@@ -1094,6 +1135,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
}
|
||||
isAmbiguous = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1233,6 +1275,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
|
||||
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;
|
||||
boolean hasConcretePolymorphic = polymorphicEvents.stream()
|
||||
.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()));
|
||||
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;
|
||||
@@ -1629,6 +1687,211 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
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) {
|
||||
MethodDeclaration enclosing = findEnclosingMethod(getterCall);
|
||||
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