This commit is contained in:
2026-07-17 16:24:00 +02:00
parent bf8bfd58ed
commit e70e11ba96
10 changed files with 2273 additions and 6 deletions

View File

@@ -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;
}
}

View File

@@ -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" : "") + '}';
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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];
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}