From e70e11ba968c13ef567f4d06616fda4dc0eaa8dd Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Fri, 17 Jul 2026 16:24:00 +0200 Subject: [PATCH] E --- .../flow/alloc/AllocationFactPropagator.java | 683 ++++++++++++++++++ .../analysis/flow/alloc/AllocationSite.java | 73 ++ .../analysis/flow/alloc/ReceiverFacts.java | 65 ++ .../alloc/VirtualAccessorConstResolver.java | 160 ++++ .../service/AbstractCallGraphEngine.java | 275 ++++++- .../alloc/AllocationSensitiveGetTypeTest.java | 277 +++++++ ...ocationSensitiveEdgeCasesPipelineTest.java | 316 ++++++++ ...locationSensitiveEnumCtorPipelineTest.java | 143 ++++ ...ocationSensitiveRichEventPipelineTest.java | 129 ++++ ...cationSensitiveRobustnessPipelineTest.java | 158 ++++ 10 files changed, 2273 insertions(+), 6 deletions(-) create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/AllocationFactPropagator.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/AllocationSite.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/ReceiverFacts.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/VirtualAccessorConstResolver.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/AllocationSensitiveGetTypeTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AllocationSensitiveEdgeCasesPipelineTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AllocationSensitiveEnumCtorPipelineTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AllocationSensitiveRichEventPipelineTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AllocationSensitiveRobustnessPipelineTest.java diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/AllocationFactPropagator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/AllocationFactPropagator.java new file mode 100644 index 0000000..358da96 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/AllocationFactPropagator.java @@ -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 methodPath, + Map> callGraph) { + return factsAt(expression, enclosingMethod, methodPath, callGraph, 0, new LinkedHashSet<>()); + } + + private ReceiverFacts factsAt( + Expression expression, + MethodDeclaration enclosingMethod, + List methodPath, + Map> callGraph, + int depth, + Set visited) { + if (expression == null || depth > MAX_DEPTH) { + return ReceiverFacts.empty(); + } + Expression peeled = peel(expression); + LinkedHashSet 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 methodPath, + Map> 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 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 methodPath, + Map> callGraph, + int depth, + Set 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 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 methodPath, + Map> callGraph, + int depth, + Set 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 edges = callGraph.get(callerMethodFqn); + if (edges == null) { + return ReceiverFacts.empty(); + } + LinkedHashSet sites = new LinkedHashSet<>(); + for (CallEdge edge : edges) { + if (edge == null || edge.getTargetMethod() == null) { + continue; + } + if (!methodsMatch(edge.getTargetMethod(), calleeMethodFqn)) { + continue; + } + List 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 methodPath, + Map> callGraph, + int depth, + Set visited) { + MethodDeclaration callerMd = findMethodDeclaration(callerMethodFqn); + if (callerMd == null || callerMd.getBody() == null) { + return ReceiverFacts.empty(); + } + String targetSimple = methodNameOf(targetMethodFqn); + LinkedHashSet 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 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; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/AllocationSite.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/AllocationSite.java new file mode 100644 index 0000000..db50e2b --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/AllocationSite.java @@ -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" : "") + '}'; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/ReceiverFacts.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/ReceiverFacts.java new file mode 100644 index 0000000..1eea7a7 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/ReceiverFacts.java @@ -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 sites; + + private ReceiverFacts(Set 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 sites) { + // Merge by type FQN, preferring sites that retain CIC AST. + Map 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 sites() { + return List.copyOf(sites); + } + + public int size() { + return sites.size(); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/VirtualAccessorConstResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/VirtualAccessorConstResolver.java new file mode 100644 index 0000000..60086eb --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/VirtualAccessorConstResolver.java @@ -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 resolveUniqueConstant(ReceiverFacts facts, String accessorName) { + if (facts == null || facts.isEmpty() || accessorName == null || accessorName.isBlank()) { + return List.of(); + } + LinkedHashSet constants = new LinkedHashSet<>(); + for (AllocationSite site : facts.sites()) { + List 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 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 viaAccessor = List.of(); + if (accessorResolver != null) { + viaAccessor = accessorResolver.resolve( + typeFqn, + accessorName, + new AccessorResolver.ReceiverContext(td, null, cu, false, null), + ResolutionBudget.defaults()); + } + LinkedHashSet 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 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 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("")) { + return null; + } + String trimmed = value.trim(); + // Enum.CONST.name() peel may already be bare; QualName may be FQN. + return trimmed; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java index 13b58a7..748633d 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java @@ -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 parsedNodeCache = new HashMap<>(); private final Map> polymorphicCallCache = new HashMap<>(); private final Map> 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 findChains(List entryPoints, List 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 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 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 values = resolveAccessorConstants(context.getFqn(currentTd), methodName, cu); - if (values != null && !values.isEmpty()) { - polymorphicEvents.addAll(values); + if (!shouldDeferVirtualAccessorChaFill(methodName, path, callGraph)) { + List 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 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 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 / 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(" on the sendEvent + // edge — recover via allocation-sensitive getType on the dispatcher frame. + List 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 path, + Map> callGraph, + List 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> graphForFacts = callGraph != null ? callGraph : this.graph; + ReceiverFacts facts = allocationFactPropagator.factsForAccessorReceiver( + accessorCall, path, graphForFacts); + List 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 path, + Map> 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 path, + Map> callGraph, + List polymorphicEvents) { + List 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 }, walk the + * dispatcher frame for {@code valueOf(receiver.getType())} / event-identity accessors and + * apply allocation-sensitive constant resolution using the call path. + */ + private List recoverAllocationSensitiveAccessorFromPath( + List path, + Map> 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 preferred = new ArrayList<>(); + List 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> graphForFacts = callGraph != null ? callGraph : this.graph; + List fromPreferred = resolveUniqueFromAccessors(preferred, path, graphForFacts); + if (fromPreferred.size() == 1) { + return fromPreferred; + } + List fromFallback = resolveUniqueFromAccessors(fallback, path, graphForFacts); + if (fromFallback.size() == 1) { + return fromFallback; + } + } + return List.of(); + } + + private List resolveUniqueFromAccessors( + List accessors, + List path, + Map> graphForFacts) { + for (MethodInvocation accessor : accessors) { + if (accessor == null) { + continue; + } + ReceiverFacts facts = allocationFactPropagator.factsForAccessorReceiver( + accessor, path, graphForFacts); + List 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) { diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/AllocationSensitiveGetTypeTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/AllocationSensitiveGetTypeTest.java new file mode 100644 index 0000000..bc7ecac --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/AllocationSensitiveGetTypeTest.java @@ -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> graph = new HashMap<>(); + graph.put("com.example.Api.pay", List.of( + new CallEdge("com.example.Dispatcher.fire", List.of("new PayRichEvent()")))); + List 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> graph = new HashMap<>(); + graph.put("com.example.Api.pay", List.of( + new CallEdge("com.example.Dispatcher.fire", List.of("Events.pay()")))); + List 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> graph = new HashMap<>(); + graph.put("com.example.Api.pay", List.of( + new CallEdge("com.example.Dispatcher.fire", + List.of("new EventWrapper(new PayRichEvent())")))); + List 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]; + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AllocationSensitiveEdgeCasesPipelineTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AllocationSensitiveEdgeCasesPipelineTest.java new file mode 100644 index 0000000..7f5794f --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AllocationSensitiveEdgeCasesPipelineTest.java @@ -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 { 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 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); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AllocationSensitiveEnumCtorPipelineTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AllocationSensitiveEnumCtorPipelineTest.java new file mode 100644 index 0000000..ed14fd8 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AllocationSensitiveEnumCtorPipelineTest.java @@ -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); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AllocationSensitiveRichEventPipelineTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AllocationSensitiveRichEventPipelineTest.java new file mode 100644 index 0000000..90e7589 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AllocationSensitiveRichEventPipelineTest.java @@ -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); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AllocationSensitiveRobustnessPipelineTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AllocationSensitiveRobustnessPipelineTest.java new file mode 100644 index 0000000..1b80c1b --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AllocationSensitiveRobustnessPipelineTest.java @@ -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); + } +}