diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/CallSiteArgBinder.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/CallSiteArgBinder.java new file mode 100644 index 0000000..e789f11 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/CallSiteArgBinder.java @@ -0,0 +1,118 @@ +package click.kamil.springstatemachineexporter.analysis.flow; + +import click.kamil.springstatemachineexporter.analysis.model.CallEdge; +import org.eclipse.jdt.core.dom.ASTVisitor; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.MethodInvocation; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Shared binder: {@link CallEdge} argument text → unique AST argument expression in the caller body. + * Fail-closed when zero or multiple sites match (no pick-any). + * Used by allocation facts and event-identity string forwarding. + */ +public final class CallSiteArgBinder { + + private CallSiteArgBinder() { + } + + /** + * @return the unique caller AST argument for {@code edge}'s {@code paramIndex}, or empty + */ + public static Optional findUniqueArgument( + MethodDeclaration callerBody, + CallEdge edge, + int paramIndex) { + if (callerBody == null || callerBody.getBody() == null || edge == null || edge.getTargetMethod() == null) { + return Optional.empty(); + } + if (paramIndex < 0) { + return Optional.empty(); + } + String targetSimple = methodNameOf(edge.getTargetMethod()); + if (targetSimple == null || targetSimple.isBlank()) { + return Optional.empty(); + } + String edgeArgText = null; + if (edge.getArguments() != null && paramIndex < edge.getArguments().size()) { + edgeArgText = normalizeExprText(edge.getArguments().get(paramIndex)); + } + List matches = new ArrayList<>(); + callerBody.accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if (!targetSimple.equals(node.getName().getIdentifier())) { + return super.visit(node); + } + if (paramIndex >= node.arguments().size()) { + return super.visit(node); + } + matches.add((Expression) node.arguments().get(paramIndex)); + return super.visit(node); + } + }); + if (matches.isEmpty()) { + return Optional.empty(); + } + if (matches.size() == 1) { + return Optional.of(matches.get(0)); + } + if (edgeArgText == null || edgeArgText.isBlank()) { + return Optional.empty(); + } + List textHits = new ArrayList<>(); + for (Expression candidate : matches) { + if (edgeArgText.equals(normalizeExprText(candidate.toString()))) { + textHits.add(candidate); + } + } + if (textHits.isEmpty()) { + return Optional.empty(); + } + // Identical arg text at several sites is equivalent — pick the first. + // Differing texts that somehow normalize equal still share one edge string. + return Optional.of(textHits.get(0)); + } + + /** + * Whether two method FQNs refer to the same simple method (path hop matching). + */ + public static boolean methodsMatch(String left, String right) { + if (left == null || right == null) { + return false; + } + if (left.equals(right)) { + return true; + } + String leftSimple = methodNameOf(left); + String rightSimple = methodNameOf(right); + return leftSimple != null && leftSimple.equals(rightSimple) + && (left.endsWith("." + rightSimple) || right.endsWith("." + leftSimple)); + } + + public static String methodNameOf(String methodFqn) { + if (methodFqn == null) { + return null; + } + int lastDot = methodFqn.lastIndexOf('.'); + return lastDot >= 0 ? methodFqn.substring(lastDot + 1) : methodFqn; + } + + public static String normalizeExprText(String text) { + if (text == null) { + return null; + } + return text.replaceAll("\\s+", ""); + } + + public static String normalizeExprText(Expression expression) { + if (expression == null) { + return null; + } + return normalizeExprText(expression.toString()); + } +} 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 index 358da96..e98a654 100644 --- 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 @@ -1,9 +1,11 @@ package click.kamil.springstatemachineexporter.analysis.flow.alloc; +import click.kamil.springstatemachineexporter.analysis.flow.CallSiteArgBinder; import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import click.kamil.springstatemachineexporter.analysis.model.CallEdge; import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer; import click.kamil.springstatemachineexporter.analysis.service.VariableTracer; +import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer; import click.kamil.springstatemachineexporter.ast.common.AstUtils; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import org.eclipse.jdt.core.dom.ASTNode; @@ -13,16 +15,21 @@ import org.eclipse.jdt.core.dom.CastExpression; import org.eclipse.jdt.core.dom.ClassInstanceCreation; import org.eclipse.jdt.core.dom.Expression; import org.eclipse.jdt.core.dom.FieldAccess; +import org.eclipse.jdt.core.dom.FieldDeclaration; +import org.eclipse.jdt.core.dom.IBinding; import org.eclipse.jdt.core.dom.IMethodBinding; import org.eclipse.jdt.core.dom.ITypeBinding; +import org.eclipse.jdt.core.dom.IVariableBinding; import org.eclipse.jdt.core.dom.MethodDeclaration; import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.Modifier; import org.eclipse.jdt.core.dom.Name; import org.eclipse.jdt.core.dom.ParenthesizedExpression; import org.eclipse.jdt.core.dom.SimpleName; import org.eclipse.jdt.core.dom.SingleVariableDeclaration; import org.eclipse.jdt.core.dom.ThisExpression; import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.eclipse.jdt.core.dom.VariableDeclarationFragment; import java.util.LinkedHashSet; import java.util.List; @@ -33,6 +40,8 @@ import java.util.Set; /** * Path-scoped allocation fact propagator: CIC → locals → callee parameters via call-edge args, * factory {@code return new T()}, and trivial getter→field→ctor-arg peels. + * Optionally treats a unique Spring {@code @Bean}/component as an alloc substitute when + * {@link InjectionPointAnalyzer} is wired (multi-bean / qualifier conflict → empty). * Fail-closed when facts are empty or ambiguous (multi-site). */ public final class AllocationFactPropagator { @@ -42,6 +51,7 @@ public final class AllocationFactPropagator { private final CodebaseContext context; private final VariableTracer variableTracer; private final ConstructorAnalyzer constructorAnalyzer; + private InjectionPointAnalyzer injectionAnalyzer; public AllocationFactPropagator(CodebaseContext context, VariableTracer variableTracer) { this(context, variableTracer, null); @@ -56,19 +66,25 @@ public final class AllocationFactPropagator { this.constructorAnalyzer = constructorAnalyzer; } + public void setInjectionAnalyzer(InjectionPointAnalyzer injectionAnalyzer) { + this.injectionAnalyzer = injectionAnalyzer; + } + /** * Allocation facts for {@code expression} within {@code enclosingMethod}, optionally * refined by walking {@code methodPath} call edges backward for parameter receivers. + * Dominating {@code instanceof} / pattern narrowing is merged via {@link InstanceofNarrower}. */ public ReceiverFacts factsAt( Expression expression, MethodDeclaration enclosingMethod, List methodPath, Map> callGraph) { - return factsAt(expression, enclosingMethod, methodPath, callGraph, 0, new LinkedHashSet<>()); + ReceiverFacts base = factsAtRaw(expression, enclosingMethod, methodPath, callGraph, 0, new LinkedHashSet<>()); + return InstanceofNarrower.refine(base, expression, expression, context); } - private ReceiverFacts factsAt( + private ReceiverFacts factsAtRaw( Expression expression, MethodDeclaration enclosingMethod, List methodPath, @@ -116,7 +132,7 @@ public final class AllocationFactPropagator { // Follow aliases / casts / factories: RichEvent carrier = e; e = (RichEvent) o; String defKey = "alias:" + System.identityHashCode(defPeeled); if (visited.add(defKey)) { - ReceiverFacts nested = factsAt( + ReceiverFacts nested = factsAtRaw( defPeeled, enclosingMethod, methodPath, callGraph, depth + 1, visited); sites.addAll(nested.sites()); } @@ -146,11 +162,62 @@ public final class AllocationFactPropagator { } } + // Instance field: unique path-scoped write this.f = provenParam/CIC (or setF(...)). + ReceiverFacts fromField = factsFromInstanceFieldStore( + peeled, enclosingMethod, methodPath, callGraph, depth, visited); + if (!fromField.isEmpty()) { + return fromField; + } + + // Unique Spring bean injected into an interface/abstract field (when analyzer is wired). + ReceiverFacts fromBean = factsFromUniqueInjectedBean(peeled); + if (!fromBean.isEmpty()) { + return fromBean; + } + return ReceiverFacts.empty(); } + private ReceiverFacts factsFromUniqueInjectedBean(Expression expression) { + if (injectionAnalyzer == null || expression == null) { + return ReceiverFacts.empty(); + } + IVariableBinding varBinding = null; + Expression peeled = peel(expression); + if (peeled instanceof SimpleName sn) { + IBinding binding = sn.resolveBinding(); + if (binding instanceof IVariableBinding vb) { + varBinding = vb; + } + } else if (peeled instanceof FieldAccess fa) { + varBinding = fa.resolveFieldBinding(); + } + if (varBinding == null) { + return ReceiverFacts.empty(); + } + String concreteFqn = injectionAnalyzer.resolveInjectedBeanFqn(varBinding); + if (concreteFqn == null || concreteFqn.isBlank()) { + return ReceiverFacts.empty(); + } + TypeDeclaration td = context.getTypeDeclaration(concreteFqn); + if (td == null) { + String simple = concreteFqn.contains(".") + ? concreteFqn.substring(concreteFqn.lastIndexOf('.') + 1) + : concreteFqn; + td = context.getTypeDeclaration(simple); + if (td != null) { + concreteFqn = context.getFqn(td); + } + } + if (td == null || td.isInterface() || Modifier.isAbstract(td.getModifiers())) { + return ReceiverFacts.empty(); + } + return ReceiverFacts.of(new AllocationSite(concreteFqn)); + } + /** * Facts for the receiver of a virtual accessor call ({@code e.getType()}). + * Uses the accessor invocation as instanceof use-site so then-branch narrowing applies. */ public ReceiverFacts factsForAccessorReceiver( MethodInvocation accessorCall, @@ -164,7 +231,9 @@ public final class AllocationFactPropagator { return ReceiverFacts.empty(); } MethodDeclaration enclosing = findEnclosingMethod(accessorCall); - return factsAt(receiver, enclosing, methodPath, callGraph); + ReceiverFacts base = factsAtRaw( + receiver, enclosing, methodPath, callGraph, 0, new LinkedHashSet<>()); + return InstanceofNarrower.refine(base, receiver, accessorCall, context); } private ReceiverFacts factsFromFactoryReturn(MethodInvocation mi, int depth, Set visited) { @@ -207,22 +276,238 @@ public final class AllocationFactPropagator { return ReceiverFacts.empty(); } try { - ReceiverFacts receiverFacts = factsAt( + ReceiverFacts receiverFacts = factsAtRaw( mi.getExpression(), enclosingMethod, methodPath, callGraph, depth + 1, visited); AllocationSite unique = receiverFacts.uniqueOrNull(); - if (unique == null || unique.cic() == null) { - return ReceiverFacts.empty(); + if (unique != null && unique.cic() != null) { + Expression ctorArg = resolveGetterToCtorArg(unique, mi.getName().getIdentifier()); + if (ctorArg != null) { + return factsAtRaw(ctorArg, enclosingMethod, methodPath, callGraph, depth + 1, visited); + } } - Expression ctorArg = resolveGetterToCtorArg(unique, mi.getName().getIdentifier()); - if (ctorArg == null) { - return ReceiverFacts.empty(); + // Trivial setter peel: w.setEvent(new T()); … w.getEvent() + String fieldName = javaBeansFieldName(mi.getName().getIdentifier()); + ReceiverFacts fromSetter = factsFromUniqueSetterWrite( + mi.getExpression(), + fieldName, + mi.getName().getIdentifier(), + enclosingMethod, + methodPath, + callGraph, + depth, + visited); + if (!fromSetter.isEmpty()) { + return fromSetter; } - return factsAt(ctorArg, enclosingMethod, methodPath, callGraph, depth + 1, visited); + return ReceiverFacts.empty(); } finally { visited.remove(peelKey); } } + /** + * Path-scoped instance field store: {@code this.carrier = e} or {@code setCarrier(e)} with a + * unique proven RHS on methods appearing on {@code methodPath}. + */ + private ReceiverFacts factsFromInstanceFieldStore( + Expression peeled, + MethodDeclaration enclosingMethod, + List methodPath, + Map> callGraph, + int depth, + Set visited) { + if (enclosingMethod == null || methodPath == null || depth > MAX_DEPTH) { + return ReceiverFacts.empty(); + } + String fieldName = instanceFieldName(peeled); + if (fieldName == null) { + return ReceiverFacts.empty(); + } + TypeDeclaration owner = findEnclosingType(enclosingMethod); + if (owner == null || !ownerDeclaresField(owner, fieldName)) { + return ReceiverFacts.empty(); + } + String visitKey = "fieldStore:" + context.getFqn(owner) + "#" + fieldName; + if (!visited.add(visitKey)) { + return ReceiverFacts.empty(); + } + try { + LinkedHashSet sites = new LinkedHashSet<>(); + int writeCount = 0; + for (String hop : methodPath) { + if (hop == null || !hopContainsType(hop, owner)) { + continue; + } + MethodDeclaration md = findMethodDeclaration(hop); + if (md == null || md.getBody() == null) { + continue; + } + List rhsList = collectFieldWrites(md, fieldName); + for (Expression rhs : rhsList) { + writeCount++; + ReceiverFacts nested = factsAtRaw(rhs, md, methodPath, callGraph, depth + 1, visited); + sites.addAll(nested.sites()); + } + } + if (writeCount == 0 || sites.isEmpty()) { + return ReceiverFacts.empty(); + } + // Ambiguous: multiple writes that disagree → fail closed via ofAll size; also reject + // multiple writes even if they agree on type (safer path-scoped uniqueness). + if (writeCount != 1) { + return ReceiverFacts.empty(); + } + return ReceiverFacts.ofAll(sites); + } finally { + visited.remove(visitKey); + } + } + + /** + * Unique {@code receiver.setX(arg)} / {@code setX(arg)} on path whose arg has allocation facts. + */ + private ReceiverFacts factsFromUniqueSetterWrite( + Expression receiver, + String fieldName, + String getterName, + MethodDeclaration enclosingMethod, + List methodPath, + Map> callGraph, + int depth, + Set visited) { + if (receiver == null || methodPath == null || fieldName == null || fieldName.isBlank()) { + return ReceiverFacts.empty(); + } + String setterName = "set" + Character.toUpperCase(fieldName.charAt(0)) + + (fieldName.length() > 1 ? fieldName.substring(1) : ""); + String receiverText = CallSiteArgBinder.normalizeExprText(receiver); + LinkedHashSet sites = new LinkedHashSet<>(); + int matchCount = 0; + Set seenBodies = new LinkedHashSet<>(); + for (String hop : methodPath) { + MethodDeclaration md = findMethodDeclaration(hop); + if (md == null || md.getBody() == null) { + continue; + } + int bodyId = System.identityHashCode(md); + if (!seenBodies.add(bodyId)) { + continue; + } + List setterArgs = collectSetterArgs(md, setterName, receiverText); + for (Expression arg : setterArgs) { + matchCount++; + ReceiverFacts nested = factsAtRaw(arg, md, methodPath, callGraph, depth + 1, visited); + sites.addAll(nested.sites()); + } + } + // Same-method setter before getter (path may not re-list enclosing). + if (enclosingMethod != null && enclosingMethod.getBody() != null + && seenBodies.add(System.identityHashCode(enclosingMethod))) { + List local = collectSetterArgs(enclosingMethod, setterName, receiverText); + for (Expression arg : local) { + matchCount++; + ReceiverFacts nested = factsAtRaw( + arg, enclosingMethod, methodPath, callGraph, depth + 1, visited); + sites.addAll(nested.sites()); + } + } + if (matchCount != 1 || sites.isEmpty()) { + return ReceiverFacts.empty(); + } + return ReceiverFacts.ofAll(sites); + } + + private static String instanceFieldName(Expression peeled) { + if (peeled instanceof FieldAccess fa + && (fa.getExpression() instanceof ThisExpression || fa.getExpression() == null)) { + return fa.getName().getIdentifier(); + } + if (peeled instanceof SimpleName sn) { + return sn.getIdentifier(); + } + return null; + } + + private static boolean ownerDeclaresField(TypeDeclaration owner, String fieldName) { + if (owner == null || fieldName == null) { + return false; + } + for (FieldDeclaration fd : owner.getFields()) { + for (Object fragObj : fd.fragments()) { + if (fragObj instanceof VariableDeclarationFragment frag + && fieldName.equals(frag.getName().getIdentifier())) { + return true; + } + } + } + return false; + } + + private boolean hopContainsType(String methodFqn, TypeDeclaration owner) { + if (methodFqn == null || owner == null) { + return false; + } + String typeFqn = context.getFqn(owner); + if (typeFqn != null && methodFqn.startsWith(typeFqn + ".")) { + return true; + } + String simple = owner.getName().getIdentifier(); + return methodFqn.contains("." + simple + ".") || methodFqn.startsWith(simple + "."); + } + + private static List collectFieldWrites(MethodDeclaration method, String fieldName) { + List writes = new java.util.ArrayList<>(); + method.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(Assignment node) { + Expression lhs = node.getLeftHandSide(); + if (lhs instanceof SimpleName sn && fieldName.equals(sn.getIdentifier())) { + writes.add(node.getRightHandSide()); + } else if (lhs instanceof FieldAccess fa + && fieldName.equals(fa.getName().getIdentifier()) + && (fa.getExpression() instanceof ThisExpression || fa.getExpression() == null)) { + writes.add(node.getRightHandSide()); + } + return super.visit(node); + } + + @Override + public boolean visit(MethodInvocation node) { + String name = node.getName().getIdentifier(); + if (name != null && name.startsWith("set") && name.length() > 3 + && javaBeansFieldName(name).equals(fieldName) + && node.arguments().size() == 1 + && (node.getExpression() == null || node.getExpression() instanceof ThisExpression)) { + writes.add((Expression) node.arguments().get(0)); + } + return super.visit(node); + } + }); + return writes; + } + + private static List collectSetterArgs( + MethodDeclaration method, String setterName, String receiverText) { + List args = new java.util.ArrayList<>(); + method.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if (!setterName.equals(node.getName().getIdentifier()) || node.arguments().size() != 1) { + return super.visit(node); + } + Expression recv = node.getExpression(); + if (recv == null) { + return super.visit(node); + } + if (receiverText != null && receiverText.equals(CallSiteArgBinder.normalizeExprText(recv))) { + args.add((Expression) node.arguments().get(0)); + } + return super.visit(node); + } + }); + return args; + } + private Expression resolveGetterToCtorArg(AllocationSite site, String getterName) { ClassInstanceCreation cic = site.cic(); if (cic == null || getterName == null) { @@ -435,7 +720,7 @@ public final class AllocationFactPropagator { if (edge == null || edge.getTargetMethod() == null) { continue; } - if (!methodsMatch(edge.getTargetMethod(), calleeMethodFqn)) { + if (!CallSiteArgBinder.methodsMatch(edge.getTargetMethod(), calleeMethodFqn)) { continue; } List args = edge.getArguments(); @@ -443,8 +728,14 @@ public final class AllocationFactPropagator { continue; } // Prefer AST so nested CIC args stay attached for getter peels. - ReceiverFacts nested = factsFromCallerAstArgument( - callerMethodFqn, edge.getTargetMethod(), paramIndex, methodPath, callGraph, depth, visited); + MethodDeclaration callerMd = findMethodDeclaration(callerMethodFqn); + ReceiverFacts nested = ReceiverFacts.empty(); + if (callerMd != null) { + var bound = CallSiteArgBinder.findUniqueArgument(callerMd, edge, paramIndex); + if (bound.isPresent()) { + nested = factsAtRaw(bound.get(), callerMd, methodPath, callGraph, depth + 1, visited); + } + } if (!nested.isEmpty()) { sites.addAll(nested.sites()); continue; @@ -457,38 +748,6 @@ public final class AllocationFactPropagator { return ReceiverFacts.ofAll(sites); } - private ReceiverFacts factsFromCallerAstArgument( - String callerMethodFqn, - String targetMethodFqn, - int paramIndex, - List 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; @@ -649,29 +908,18 @@ public final class AllocationFactPropagator { } private static String methodNameOf(String methodFqn) { - if (methodFqn == null) { - return null; - } - int lastDot = methodFqn.lastIndexOf('.'); - return lastDot >= 0 ? methodFqn.substring(lastDot + 1) : methodFqn; + return CallSiteArgBinder.methodNameOf(methodFqn); } private static boolean methodsMatch(String left, String right) { - if (left == null || right == null) { - return false; - } - if (left.equals(right)) { - return true; - } - return methodNameOf(left).equals(methodNameOf(right)) - && (left.endsWith("." + methodNameOf(right)) || right.endsWith("." + methodNameOf(left))); + return CallSiteArgBinder.methodsMatch(left, right); } private static int indexOfMethodOnPath(List methodPath, String methodFqn) { if (methodPath == null || methodFqn == null) { return -1; } - String simple = methodNameOf(methodFqn); + String simple = CallSiteArgBinder.methodNameOf(methodFqn); for (int i = methodPath.size() - 1; i >= 0; i--) { String hop = methodPath.get(i); if (hop != null && (hop.equals(methodFqn) || hop.endsWith("." + simple))) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/InstanceofNarrower.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/InstanceofNarrower.java new file mode 100644 index 0000000..bef5df3 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/InstanceofNarrower.java @@ -0,0 +1,265 @@ +package click.kamil.springstatemachineexporter.analysis.flow.alloc; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.ASTNode; +import org.eclipse.jdt.core.dom.ASTVisitor; +import org.eclipse.jdt.core.dom.CastExpression; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.IfStatement; +import org.eclipse.jdt.core.dom.InstanceofExpression; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.Modifier; +import org.eclipse.jdt.core.dom.ParenthesizedExpression; +import org.eclipse.jdt.core.dom.PatternInstanceofExpression; +import org.eclipse.jdt.core.dom.SimpleName; +import org.eclipse.jdt.core.dom.SingleVariableDeclaration; +import org.eclipse.jdt.core.dom.Statement; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.eclipse.jdt.core.dom.TypePattern; + +/** + * Dominating {@code instanceof} / pattern narrowing → unique concrete {@link AllocationSite}. + * Shared by {@link AllocationFactPropagator}; fail-closed for else-branch, abstract targets, + * and sends outside the narrow. + */ +public final class InstanceofNarrower { + + private InstanceofNarrower() { + } + + /** + * Facts when {@code useSite} is dominated by {@code recv instanceof T} (then-branch) + * or {@code recv} is pattern variable {@code T p}. + */ + public static ReceiverFacts factsAt( + Expression receiver, + ASTNode useSite, + CodebaseContext context) { + if (context == null || receiver == null || useSite == null) { + return ReceiverFacts.empty(); + } + Expression peeledRecv = peel(receiver); + if (!(peeledRecv instanceof SimpleName recvSn)) { + return ReceiverFacts.empty(); + } + String recvName = recvSn.getIdentifier(); + MethodDeclaration enclosing = findEnclosingMethod(useSite); + String patternType = resolvePatternVariableType(recvName, enclosing); + if (patternType != null) { + return factsFromConcreteTypeName(patternType, context); + } + ASTNode current = useSite; + while (current != null && !(current instanceof MethodDeclaration)) { + ASTNode parent = current.getParent(); + if (parent instanceof IfStatement ifStmt) { + if (isNodeUnder(ifStmt.getThenStatement(), current)) { + ReceiverFacts fromCond = extractInstanceofFacts( + ifStmt.getExpression(), recvName, context); + if (!fromCond.isEmpty()) { + return fromCond; + } + } + } + current = parent; + } + return ReceiverFacts.empty(); + } + + /** + * Merge base AFP facts with dominating instanceof. + *
    + *
  • instanceof empty → base unchanged
  • + *
  • base empty → instanceof
  • + *
  • base unique is Object / missing TD / interface / abstract → refine to instanceof
  • + *
  • base unique concrete equals instanceof → that site
  • + *
  • base unique concrete conflicts with instanceof → empty (fail-closed)
  • + *
+ */ + public static ReceiverFacts refine( + ReceiverFacts base, + Expression receiver, + ASTNode useSite, + CodebaseContext context) { + ReceiverFacts narrowed = factsAt(receiver, useSite, context); + if (narrowed.isEmpty()) { + return base == null ? ReceiverFacts.empty() : base; + } + if (base == null || base.isEmpty()) { + return narrowed; + } + AllocationSite baseUnique = base.uniqueOrNull(); + AllocationSite narrowUnique = narrowed.uniqueOrNull(); + if (baseUnique == null || narrowUnique == null) { + return ReceiverFacts.empty(); + } + if (isWeakAllocSite(baseUnique, context)) { + return narrowed; + } + if (sameType(baseUnique.typeFqn(), narrowUnique.typeFqn())) { + return ReceiverFacts.of(baseUnique.preferRich(narrowUnique)); + } + return ReceiverFacts.empty(); + } + + public static String resolvePatternVariableType(String varName, MethodDeclaration enclosing) { + if (varName == null || enclosing == null || enclosing.getBody() == null) { + return null; + } + String[] found = {null}; + enclosing.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(PatternInstanceofExpression node) { + SingleVariableDeclaration svd = patternVariableOf(node); + if (svd != null + && varName.equals(svd.getName().getIdentifier()) + && svd.getType() != null) { + found[0] = svd.getType().toString(); + return false; + } + return super.visit(node); + } + }); + if (found[0] == null) { + return null; + } + String type = found[0]; + int generic = type.indexOf('<'); + return generic >= 0 ? type.substring(0, generic) : type; + } + + private static boolean isWeakAllocSite(AllocationSite site, CodebaseContext context) { + if (site == null || site.typeFqn() == null || site.typeFqn().isBlank()) { + return true; + } + String fqn = site.typeFqn(); + if ("java.lang.Object".equals(fqn) || "Object".equals(fqn)) { + return true; + } + TypeDeclaration td = resolveTypeDeclaration(fqn, context); + if (td == null) { + return true; + } + return td.isInterface() || Modifier.isAbstract(td.getModifiers()); + } + + private static boolean sameType(String a, String b) { + if (a == null || b == null) { + return false; + } + if (a.equals(b)) { + return true; + } + String simpleA = a.contains(".") ? a.substring(a.lastIndexOf('.') + 1) : a; + String simpleB = b.contains(".") ? b.substring(b.lastIndexOf('.') + 1) : b; + return simpleA.equals(simpleB); + } + + private static ReceiverFacts extractInstanceofFacts( + Expression condition, + String recvName, + CodebaseContext context) { + Expression cond = peel(condition); + if (cond instanceof InstanceofExpression io) { + Expression left = peel(io.getLeftOperand()); + if (left instanceof SimpleName sn && recvName.equals(sn.getIdentifier()) + && io.getRightOperand() != null) { + return factsFromConcreteTypeName(io.getRightOperand().toString(), context); + } + } + if (cond instanceof PatternInstanceofExpression pio) { + Expression left = peel(pio.getLeftOperand()); + if (left instanceof SimpleName sn && recvName.equals(sn.getIdentifier())) { + SingleVariableDeclaration svd = patternVariableOf(pio); + if (svd != null && svd.getType() != null) { + return factsFromConcreteTypeName(svd.getType().toString(), context); + } + } + } + return ReceiverFacts.empty(); + } + + private static ReceiverFacts factsFromConcreteTypeName(String typeFqn, CodebaseContext context) { + if (typeFqn == null || typeFqn.isBlank()) { + return ReceiverFacts.empty(); + } + int generic = typeFqn.indexOf('<'); + if (generic >= 0) { + typeFqn = typeFqn.substring(0, generic); + } + TypeDeclaration td = resolveTypeDeclaration(typeFqn, context); + if (td == null || td.isInterface() || Modifier.isAbstract(td.getModifiers())) { + return ReceiverFacts.empty(); + } + String fqn = context.getFqn(td); + return ReceiverFacts.of(new AllocationSite(fqn != null ? fqn : typeFqn)); + } + + private static TypeDeclaration resolveTypeDeclaration(String typeFqn, CodebaseContext context) { + TypeDeclaration td = context.getTypeDeclaration(typeFqn); + if (td == null) { + String simple = typeFqn.contains(".") ? typeFqn.substring(typeFqn.lastIndexOf('.') + 1) : typeFqn; + td = context.getTypeDeclaration(simple); + } + return td; + } + + private static SingleVariableDeclaration patternVariableOf(PatternInstanceofExpression node) { + if (node == null) { + return null; + } + try { + if (node.getPattern() instanceof TypePattern tp) { + SingleVariableDeclaration pv = tp.getPatternVariable(); + if (pv != null) { + return pv; + } + } + } catch (RuntimeException ignored) { + // API level may expose rightOperand instead of pattern + } + try { + return node.getRightOperand(); + } catch (RuntimeException ignored) { + return null; + } + } + + private static boolean isNodeUnder(Statement root, ASTNode node) { + if (root == null || node == null) { + return false; + } + ASTNode current = node; + while (current != null) { + if (current == root) { + return true; + } + if (current instanceof MethodDeclaration) { + return false; + } + current = current.getParent(); + } + return false; + } + + private static MethodDeclaration findEnclosingMethod(ASTNode node) { + ASTNode current = node; + while (current != null) { + if (current instanceof MethodDeclaration md) { + return md; + } + current = current.getParent(); + } + return null; + } + + private static Expression peel(Expression expression) { + Expression current = expression; + while (current instanceof ParenthesizedExpression pe) { + current = pe.getExpression(); + } + if (current instanceof CastExpression ce) { + return peel(ce.getExpression()); + } + return current; + } +} 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 index 60086eb..a0ffb42 100644 --- 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 @@ -146,7 +146,7 @@ public final class VirtualAccessorConstResolver { /** * Drop symbolic / enum-set / blank; keep FQN or bare constant identifiers. */ - static String normalizeConstant(String value) { + public static String normalizeConstant(String value) { if (value == null || value.isBlank()) { return null; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/AccessProof.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/AccessProof.java new file mode 100644 index 0000000..b3727b2 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/AccessProof.java @@ -0,0 +1,49 @@ +package click.kamil.springstatemachineexporter.analysis.flow.eventid; + +import click.kamil.springstatemachineexporter.analysis.flow.alloc.ReceiverFacts; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.MethodInvocation; + +/** + * Proven virtual accessor on a receiver with allocation (or concrete-type) facts. + */ +public final class AccessProof { + + private final ReceiverFacts receiverFacts; + private final String accessorName; + private final MethodInvocation accessorCall; + private final MethodDeclaration enclosingMethod; + + public AccessProof( + ReceiverFacts receiverFacts, + String accessorName, + MethodInvocation accessorCall, + MethodDeclaration enclosingMethod) { + this.receiverFacts = receiverFacts == null ? ReceiverFacts.empty() : receiverFacts; + this.accessorName = accessorName; + this.accessorCall = accessorCall; + this.enclosingMethod = enclosingMethod; + } + + public ReceiverFacts receiverFacts() { + return receiverFacts; + } + + public String accessorName() { + return accessorName; + } + + public MethodInvocation accessorCall() { + return accessorCall; + } + + public MethodDeclaration enclosingMethod() { + return enclosingMethod; + } + + public boolean isValid() { + return accessorName != null + && !accessorName.isBlank() + && !receiverFacts.isEmpty(); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityResolver.java new file mode 100644 index 0000000..2ccb6a1 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/flow/eventid/EventIdentityResolver.java @@ -0,0 +1,1509 @@ +package click.kamil.springstatemachineexporter.analysis.flow.eventid; + +import click.kamil.springstatemachineexporter.analysis.flow.CallSiteArgBinder; +import click.kamil.springstatemachineexporter.analysis.flow.alloc.AllocationFactPropagator; +import click.kamil.springstatemachineexporter.analysis.flow.alloc.AllocationSite; +import click.kamil.springstatemachineexporter.analysis.flow.alloc.InstanceofNarrower; +import click.kamil.springstatemachineexporter.analysis.flow.alloc.ReceiverFacts; +import click.kamil.springstatemachineexporter.analysis.flow.alloc.VirtualAccessorConstResolver; +import click.kamil.springstatemachineexporter.analysis.model.CallEdge; +import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry; +import click.kamil.springstatemachineexporter.analysis.pipeline.MessageCarrierSupport; +import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper; +import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport; +import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; +import click.kamil.springstatemachineexporter.analysis.service.FunctionalInterfaceTypes; +import click.kamil.springstatemachineexporter.analysis.service.VariableTracer; +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.ConditionalExpression; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.FieldAccess; +import org.eclipse.jdt.core.dom.FieldDeclaration; +import org.eclipse.jdt.core.dom.ITypeBinding; +import org.eclipse.jdt.core.dom.IfStatement; +import org.eclipse.jdt.core.dom.InfixExpression; +import org.eclipse.jdt.core.dom.LambdaExpression; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.Modifier; +import org.eclipse.jdt.core.dom.Name; +import org.eclipse.jdt.core.dom.ParenthesizedExpression; +import org.eclipse.jdt.core.dom.QualifiedName; +import org.eclipse.jdt.core.dom.ReturnStatement; +import org.eclipse.jdt.core.dom.SimpleName; +import org.eclipse.jdt.core.dom.SingleVariableDeclaration; +import org.eclipse.jdt.core.dom.StringLiteral; +import org.eclipse.jdt.core.dom.SwitchCase; +import org.eclipse.jdt.core.dom.SwitchStatement; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.eclipse.jdt.core.dom.VariableDeclarationFragment; +import org.eclipse.jdt.core.dom.VariableDeclarationStatement; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Unified peeler: sendEvent/valueOf/helper/get* expressions → unique compile-time event constant + * when allocation (or concrete declared type) proves a single accessor override. + * Also forwards string carriers across call edges so {@code valueOf(code)} can resolve when + * the caller passed {@code e.getA()}. + *

+ * Intentionally fail-closed forever for: multi-concrete Flux/sendEvents fan-out, opaque external + * validate wrappers, Mystery/runtime/IO carriers, TX {@code afterCommit} / {@code @Async} handoffs, + * and unbound request-param {@code valueOf}. + */ +public final class EventIdentityResolver { + + private static final int MAX_DEPTH = 10; + private static final int MAX_LOCAL_HOPS = 6; + + /** Single-arg fire sites treated like {@code sendEvent} for path recovery. */ + private static final Set SEND_LIKE_NAMES = Set.of( + "sendEvent", + "fire", + "fireEvent", + "dispatchEvent", + "send", + "sendEvents", + "sendEventCollect", + "sendEventMono", + "trigger"); + + /** Identity-preserving String instance methods (0-arg only). */ + private static final Set IDENTITY_STRING_PEELS = Set.of( + "trim", "strip", "toUpperCase", "toLowerCase"); + + /** Compact 0-arg identity accessor names; still gated by unique compile-time body constant. */ + private static final Set COMPACT_ACCESSOR_NAMES = Set.of( + "type", "event", "code", "a", "id"); + + private final CodebaseContext context; + private final AllocationFactPropagator allocationFactPropagator; + private final VirtualAccessorConstResolver virtualAccessorConstResolver; + private final VariableTracer variableTracer; + private final ConstantResolver constantResolver; + + /** Path-scoped result cache: expression identity + path key → proven constant or miss marker. */ + private final Map> resolveCache = new HashMap<>(); + + public EventIdentityResolver( + CodebaseContext context, + AllocationFactPropagator allocationFactPropagator, + VirtualAccessorConstResolver virtualAccessorConstResolver, + VariableTracer variableTracer, + ConstantResolver constantResolver) { + this.context = context; + this.allocationFactPropagator = allocationFactPropagator; + this.virtualAccessorConstResolver = virtualAccessorConstResolver; + this.variableTracer = variableTracer; + this.constantResolver = constantResolver; + } + + public void clearSessionCache() { + resolveCache.clear(); + } + + /** + * @return singleton list with proven constant, or empty when unknown / ambiguous + */ + public List resolveUnique( + Expression expression, + List methodPath, + Map> callGraph) { + return resolveUnique(expression, methodPath, callGraph, 0, new LinkedHashSet<>()); + } + + /** + * Peel expression to an {@link AccessProof} when a virtual accessor + receiver facts are proven. + */ + public AccessProof proveAccess( + Expression expression, + List methodPath, + Map> callGraph) { + if (expression == null) { + return null; + } + Expression peeled = peel(expression); + MethodInvocation accessor = findVirtualAccessorInvocation(peeled); + if (accessor == null) { + return null; + } + MethodDeclaration enclosing = findEnclosingMethod(accessor); + ReceiverFacts facts = allocationFactPropagator == null + ? ReceiverFacts.empty() + : allocationFactPropagator.factsForAccessorReceiver(accessor, methodPath, callGraph); + if (facts.isEmpty()) { + facts = factsFromConcreteReceiverType( + accessor, enclosing, methodPath, callGraph, 0, new LinkedHashSet<>()); + } + AccessProof proof = new AccessProof( + facts, accessor.getName().getIdentifier(), accessor, enclosing); + return proof.isValid() ? proof : null; + } + + /** + * Path recovery: scan method frames for candidate exprs and resolve with the same peels. + */ + public List recoverFromPath( + List methodPath, + Map> callGraph) { + if (methodPath == null || methodPath.size() < 2) { + return List.of(); + } + for (int i = methodPath.size() - 2; i >= 0; i--) { + String methodFqn = methodPath.get(i); + MethodDeclaration md = findMethodDeclaration(methodFqn); + 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(); + // Only fish expressions tied to valueOf / send-like / valueOf-helpers. + // Do not harvest bare getType() — that over-resolves through opaque wrappers. + if ("valueOf".equals(name) + && (node.arguments().size() == 1 || node.arguments().size() == 2) + && node.arguments().get(node.arguments().size() - 1) instanceof Expression arg) { + preferred.add(node); + preferred.add(arg); + return super.visit(node); + } + if (isSendLikeName(name) && node.arguments().size() == 1 + && node.arguments().get(0) instanceof Expression arg) { + preferred.add(arg); + return super.visit(node); + } + if (node.arguments().size() == 1 && looksLikeValueOfHelper(node)) { + preferred.add(node); + } + return super.visit(node); + } + }); + List fromPreferred = resolveUniqueAgreeing(preferred, methodPath, callGraph); + if (fromPreferred.size() == 1) { + return fromPreferred; + } + List fromFallback = resolveUniqueAgreeing(fallback, methodPath, callGraph); + if (fromFallback.size() == 1) { + return fromFallback; + } + } + return List.of(); + } + + /** + * Resolve preferred exprs; succeed only when every unique proof agrees on the same constant. + * Multiple different unique proofs → empty (fail-closed, no first-wins). + */ + private List resolveUniqueAgreeing( + List exprs, + List methodPath, + Map> callGraph) { + LinkedHashSet proven = new LinkedHashSet<>(); + for (Expression expr : exprs) { + List unique = resolveUnique(expr, methodPath, callGraph); + if (unique.size() == 1) { + proven.add(unique.get(0)); + if (proven.size() > 1) { + return List.of(); + } + } + } + return proven.size() == 1 ? List.copyOf(proven) : List.of(); + } + + private List resolveUnique( + Expression expression, + List methodPath, + Map> callGraph, + int depth, + Set visited) { + if (expression == null || depth > MAX_DEPTH) { + return List.of(); + } + String cacheKey = cacheKey(expression, methodPath, depth); + if (cacheKey != null) { + List cached = resolveCache.get(cacheKey); + if (cached != null) { + return cached; + } + } + + List result = resolveUniqueUncached(expression, methodPath, callGraph, depth, visited); + if (cacheKey != null && (result.size() == 1 || result.isEmpty())) { + resolveCache.put(cacheKey, result); + } + return result; + } + + private List resolveUniqueUncached( + Expression expression, + List methodPath, + Map> callGraph, + int depth, + Set visited) { + Expression peeled = peelIdentityString(peel(expression)); + + // 0. MessageBuilder / GenericMessage / allowlisted setHeader before other peels + List fromMessage = resolveFromMessageCarrier(peeled, methodPath, callGraph, depth, visited); + if (fromMessage.size() == 1) { + return fromMessage; + } + + // 0b. Literal lambda / Supplier body + if (peeled instanceof LambdaExpression lambda) { + Expression body = ReactiveExpressionSupport.lambdaBodyExpression(lambda); + if (body != null) { + return resolveUnique(body, methodPath, callGraph, depth + 1, visited); + } + return List.of(); + } + + // 1. Compile-time enum / qualified constant + if (constantResolver != null) { + String direct = constantResolver.resolve(peeled, context); + String normalized = VirtualAccessorConstResolver.normalizeConstant(direct); + if (normalized != null && looksLikeEnumConstant(normalized)) { + return List.of(normalized); + } + } + if (peeled instanceof QualifiedName qn) { + String id = qn.getName().getIdentifier(); + if (id != null && id.matches("[A-Z_][A-Z0-9_]*")) { + return List.of(id); + } + } + + // 2. Virtual accessor call (via AccessProof when possible) + if (peeled instanceof MethodInvocation mi && isVirtualAccessorName(mi.getName().getIdentifier()) + && mi.getExpression() != null + && mi.arguments().isEmpty()) { + return resolveFromAccessor(mi, methodPath, callGraph, depth, visited); + } + + // 2a. Supplier/Callable.get() when receiver binds a unique lambda on the path + if (peeled instanceof MethodInvocation getMi + && getMi.arguments().isEmpty() + && getMi.getExpression() != null + && FunctionalInterfaceTypes.isSamMethodName(getMi.getName().getIdentifier()) + && !isIdentityContainerReceiver(getMi.getExpression())) { + List fromFunctional = resolveFromFunctionalGet( + getMi, methodPath, callGraph, depth, visited); + if (fromFunctional.size() == 1) { + return fromFunctional; + } + } + + // 2a2. headers.get("event") / getHeaders().get("smEvent") + if (peeled instanceof MethodInvocation headerGet + && "get".equals(headerGet.getName().getIdentifier()) + && headerGet.arguments().size() == 1) { + List fromHeaderGet = resolveFromHeadersGet( + headerGet, methodPath, callGraph, depth, visited); + if (fromHeaderGet.size() == 1) { + return fromHeaderGet; + } + } + + // 2b. Enum.name() peel: valueOf(e.getType().name()) → resolve e.getType() + if (peeled instanceof MethodInvocation nameMi + && "name".equals(nameMi.getName().getIdentifier()) + && nameMi.arguments().isEmpty() + && nameMi.getExpression() != null) { + return resolveUnique(nameMi.getExpression(), methodPath, callGraph, depth + 1, visited); + } + + // 2c. Ternary: both arms must prove the same unique constant + if (peeled instanceof ConditionalExpression cond) { + List thenArm = resolveUnique(cond.getThenExpression(), methodPath, callGraph, depth + 1, visited); + List elseArm = resolveUnique(cond.getElseExpression(), methodPath, callGraph, depth + 1, visited); + if (thenArm.size() == 1 && elseArm.size() == 1 && thenArm.get(0).equals(elseArm.get(0))) { + return thenArm; + } + return List.of(); + } + + // 3. Enum.valueOf(expr) or Enum.valueOf(Class, String) + if (peeled instanceof MethodInvocation valueOfMi + && "valueOf".equals(valueOfMi.getName().getIdentifier())) { + if (valueOfMi.arguments().size() == 1 + && valueOfMi.arguments().get(0) instanceof Expression arg) { + return resolveUnique(arg, methodPath, callGraph, depth + 1, visited); + } + if (valueOfMi.arguments().size() == 2 + && valueOfMi.arguments().get(1) instanceof Expression arg) { + return resolveUnique(arg, methodPath, callGraph, depth + 1, visited); + } + } + + // 4. Helpers: valueOf(param), passthrough return param, or unique const-map (switch/if-equals) + if (peeled instanceof MethodInvocation helperMi && helperMi.arguments().size() == 1 + && !"valueOf".equals(helperMi.getName().getIdentifier()) + && !isVirtualAccessorName(helperMi.getName().getIdentifier())) { + List fromHelper = resolveFromValueOfHelper(helperMi, methodPath, callGraph, depth, visited); + if (!fromHelper.isEmpty()) { + return fromHelper; + } + List fromPass = resolveFromPassthroughHelper(helperMi, methodPath, callGraph, depth, visited); + if (!fromPass.isEmpty()) { + return fromPass; + } + List fromMap = resolveFromConstMapHelper(helperMi, methodPath, callGraph, depth, visited); + if (!fromMap.isEmpty()) { + return fromMap; + } + } + + // 4b. Public final identity field: valueOf(e.a) — AST FieldAccess or reparsed QualifiedName + if (peeled instanceof FieldAccess fa && fa.getExpression() != null) { + List fromField = resolveFromIdentityFieldAccess( + fa.getExpression(), fa.getName().getIdentifier(), methodPath, callGraph, depth, visited); + if (fromField.size() == 1) { + return fromField; + } + } + if (peeled instanceof QualifiedName qn + && qn.getQualifier() instanceof SimpleName + && !looksLikeEnumConstant(qn.getName().getIdentifier())) { + List fromField = resolveFromIdentityFieldAccess( + qn.getQualifier(), qn.getName().getIdentifier(), methodPath, callGraph, depth, visited); + if (fromField.size() == 1) { + return fromField; + } + } + + // 5. SimpleName → VariableTracer / local init / interprocedural string param + if (peeled instanceof SimpleName) { + if (variableTracer != null) { + for (Expression def : variableTracer.traceVariableAll(peeled)) { + Expression defPeeled = peelIdentityString(peel(def)); + if (defPeeled == null || defPeeled == peeled) { + continue; + } + String key = "sn:" + System.identityHashCode(defPeeled); + if (!visited.add(key)) { + continue; + } + List nested = resolveUnique(defPeeled, methodPath, callGraph, depth + 1, visited); + if (nested.size() == 1) { + return nested; + } + } + } + MethodDeclaration enclosing = findEnclosingMethod(peeled); + if (enclosing != null && enclosing.getBody() != null + && peeled instanceof SimpleName sn) { + Expression init = findLocalInitializer(enclosing, sn.getIdentifier()); + if (init != null) { + String key = "init:" + sn.getIdentifier() + "@" + System.identityHashCode(enclosing); + if (visited.add(key)) { + List nested = resolveUnique(init, methodPath, callGraph, depth + 1, visited); + if (nested.size() == 1) { + return nested; + } + } + } + // Interprocedural: SimpleName is a callee param → caller arg AST → recurse + List fromCaller = resolveFromCallerStringArgument( + sn, enclosing, methodPath, callGraph, depth, visited); + if (fromCaller.size() == 1) { + return fromCaller; + } + } + } + + return List.of(); + } + + /** + * When {@code code} is a parameter of the enclosing method, locate the unique caller + * argument on the path ({@code route(e.getA())}) and resolve that expression. + */ + private List resolveFromCallerStringArgument( + SimpleName paramName, + MethodDeclaration enclosing, + List methodPath, + Map> callGraph, + int depth, + Set visited) { + if (paramName == null || enclosing == null || methodPath == null || callGraph == null) { + return List.of(); + } + int paramIndex = parameterIndex(enclosing, paramName.getIdentifier()); + if (paramIndex < 0) { + return List.of(); + } + String calleeFqn = methodFqn(enclosing); + if (calleeFqn == null) { + return List.of(); + } + String visitKey = "strParam:" + calleeFqn + "#" + paramIndex; + if (!visited.add(visitKey)) { + return List.of(); + } + int calleeIdx = methodPath.indexOf(calleeFqn); + if (calleeIdx <= 0) { + calleeIdx = indexOfMethodOnPath(methodPath, calleeFqn); + } + if (calleeIdx <= 0) { + return List.of(); + } + String callerMethodFqn = methodPath.get(calleeIdx - 1); + List edges = callGraph.get(callerMethodFqn); + if (edges == null || edges.isEmpty()) { + return List.of(); + } + LinkedHashSet proven = new LinkedHashSet<>(); + boolean sawMatchingEdge = false; + for (CallEdge edge : edges) { + if (edge == null || edge.getTargetMethod() == null) { + continue; + } + if (!CallSiteArgBinder.methodsMatch(edge.getTargetMethod(), calleeFqn)) { + continue; + } + sawMatchingEdge = true; + MethodDeclaration callerMd = findMethodDeclaration(callerMethodFqn); + Expression callerArg = callerMd == null + ? null + : CallSiteArgBinder.findUniqueArgument(callerMd, edge, paramIndex).orElse(null); + if (callerArg == null) { + // Soft miss on one edge — keep scanning; fail closed if none resolve uniquely. + continue; + } + List nested = resolveUnique(callerArg, methodPath, callGraph, depth + 1, visited); + if (nested.size() == 1) { + proven.add(nested.get(0)); + } else if (!nested.isEmpty()) { + return List.of(); + } + if (proven.size() > 1) { + return List.of(); + } + } + if (!sawMatchingEdge || proven.size() != 1) { + return List.of(); + } + return List.copyOf(proven); + } + + /** + * Pure passthrough: {@code T f(T x) { return x; }} / assert wrappers — map to caller arg. + */ + private List resolveFromPassthroughHelper( + MethodInvocation helperCall, + List methodPath, + Map> callGraph, + int depth, + Set visited) { + String key = "pass:" + System.identityHashCode(helperCall); + if (!visited.add(key) || helperCall.arguments().isEmpty()) { + return List.of(); + } + MethodDeclaration helperMd = resolveHelperMethod(helperCall); + if (helperMd == null) { + return List.of(); + } + int paramIdx = MethodInvocationUnwrapper.getReturnedParameterIndex(helperMd); + if (paramIdx < 0 || paramIdx >= helperCall.arguments().size()) { + return List.of(); + } + Expression callerArg = (Expression) helperCall.arguments().get(paramIdx); + return resolveUnique(callerArg, methodPath, callGraph, depth + 1, visited); + } + + /** + * Const-map helpers: switch/if-equals on the string param → enum constants. + * Succeeds only when the caller arg proves a unique key that maps to one constant. + */ + private List resolveFromConstMapHelper( + MethodInvocation helperCall, + List methodPath, + Map> callGraph, + int depth, + Set visited) { + String key = "cmap:" + System.identityHashCode(helperCall); + if (!visited.add(key) || helperCall.arguments().isEmpty()) { + return List.of(); + } + MethodDeclaration helperMd = resolveHelperMethod(helperCall); + if (helperMd == null || helperMd.getBody() == null || helperMd.parameters().isEmpty()) { + return List.of(); + } + Expression callerArg = (Expression) helperCall.arguments().get(0); + List argKeys = resolveUnique(callerArg, methodPath, callGraph, depth + 1, visited); + if (argKeys.size() != 1) { + return List.of(); + } + String keyConst = VirtualAccessorConstResolver.normalizeConstant(argKeys.get(0)); + if (keyConst == null) { + keyConst = argKeys.get(0); + } + if (keyConst.contains(".")) { + keyConst = keyConst.substring(keyConst.lastIndexOf('.') + 1); + } + Map caseToConst = extractStringCaseMap(helperMd); + if (caseToConst.isEmpty()) { + return List.of(); + } + String mapped = caseToConst.get(keyConst); + if (mapped == null) { + mapped = caseToConst.get(argKeys.get(0)); + } + if (mapped == null) { + return List.of(); + } + String normalized = VirtualAccessorConstResolver.normalizeConstant(mapped); + if (normalized != null && looksLikeEnumConstant(normalized)) { + return List.of(normalized); + } + return looksLikeEnumConstant(mapped) ? List.of(mapped) : List.of(); + } + + private Map extractStringCaseMap(MethodDeclaration method) { + Map map = new HashMap<>(); + if (method.getBody() == null) { + return map; + } + method.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(SwitchStatement node) { + String currentKey = null; + for (Object stmtObj : node.statements()) { + if (stmtObj instanceof SwitchCase sc) { + if (sc.isDefault()) { + currentKey = null; + continue; + } + currentKey = null; + for (Object exprObj : sc.expressions()) { + if (exprObj instanceof StringLiteral sl) { + currentKey = sl.getLiteralValue(); + } else if (exprObj instanceof Expression ex && constantResolver != null) { + String resolved = constantResolver.resolve(ex, context); + if (resolved != null) { + currentKey = VirtualAccessorConstResolver.normalizeConstant(resolved); + if (currentKey != null && currentKey.contains(".")) { + currentKey = currentKey.substring(currentKey.lastIndexOf('.') + 1); + } + } + } + } + } else if (stmtObj instanceof ReturnStatement rs && currentKey != null) { + String constVal = resolveReturnConstant(rs.getExpression()); + if (constVal != null) { + map.put(currentKey, constVal); + } + currentKey = null; + } + } + return false; + } + + @Override + public boolean visit(IfStatement node) { + String lit = extractEqualsStringLiteral(node.getExpression()); + if (lit != null && node.getThenStatement() != null) { + node.getThenStatement().accept(new ASTVisitor() { + @Override + public boolean visit(ReturnStatement rs) { + String constVal = resolveReturnConstant(rs.getExpression()); + if (constVal != null) { + map.put(lit, constVal); + } + return false; + } + }); + } + return super.visit(node); + } + }); + return map; + } + + private String resolveReturnConstant(Expression expression) { + if (expression == null || constantResolver == null) { + return null; + } + Expression peeled = peel(expression); + String direct = constantResolver.resolve(peeled, context); + String normalized = VirtualAccessorConstResolver.normalizeConstant(direct); + if (normalized != null && looksLikeEnumConstant(normalized)) { + return normalized; + } + if (peeled instanceof QualifiedName qn) { + String id = qn.getName().getIdentifier(); + if (looksLikeEnumConstant(id)) { + return id; + } + } + return null; + } + + private static String extractEqualsStringLiteral(Expression condition) { + Expression peeled = peel(condition); + if (peeled instanceof MethodInvocation mi + && "equals".equals(mi.getName().getIdentifier()) + && mi.arguments().size() == 1) { + Expression recv = mi.getExpression(); + Expression arg = (Expression) mi.arguments().get(0); + if (recv instanceof StringLiteral sl) { + return sl.getLiteralValue(); + } + if (arg instanceof StringLiteral sl) { + return sl.getLiteralValue(); + } + } + if (peeled instanceof InfixExpression infix + && InfixExpression.Operator.EQUALS.equals(infix.getOperator())) { + if (infix.getLeftOperand() instanceof StringLiteral sl) { + return sl.getLiteralValue(); + } + if (infix.getRightOperand() instanceof StringLiteral sl) { + return sl.getLiteralValue(); + } + } + return null; + } + + private List resolveFromAccessor( + MethodInvocation accessorCall, + List methodPath, + Map> callGraph, + int depth, + Set visited) { + if (allocationFactPropagator == null || virtualAccessorConstResolver == null) { + return List.of(); + } + AccessProof proof = proveAccess(accessorCall, methodPath, callGraph); + if (proof != null && proof.isValid()) { + List proven = virtualAccessorConstResolver.resolveUniqueConstant( + proof.receiverFacts(), proof.accessorName()); + if (proven.size() == 1) { + return proven; + } + } + // Optional.of(e).get().getA() — peel identity wrappers on the receiver, then re-prove. + Expression rawRecv = accessorCall.getExpression(); + Expression peeledRecv = peelIdentityString(peel(rawRecv)); + if (peeledRecv != null && peeledRecv != rawRecv) { + MethodDeclaration enclosing = findEnclosingMethod(accessorCall); + ReceiverFacts facts = allocationFactPropagator.factsAt( + peeledRecv, enclosing, methodPath, callGraph); + if (facts.isEmpty()) { + facts = factsFromConcreteReceiverTypeOnExpr( + peeledRecv, enclosing, methodPath, callGraph); + } + if (!facts.isEmpty()) { + return virtualAccessorConstResolver.resolveUniqueConstant( + facts, accessorCall.getName().getIdentifier()); + } + } + return List.of(); + } + + private ReceiverFacts factsFromConcreteReceiverTypeOnExpr( + Expression receiver, + MethodDeclaration enclosing, + List methodPath, + Map> callGraph) { + if (receiver == null) { + return ReceiverFacts.empty(); + } + String typeFqn = resolveExpressionTypeFqn(receiver, enclosing); + if (typeFqn == null) { + typeFqn = resolveParamTypeFromAst(receiver, enclosing); + } + if (typeFqn == null) { + return ReceiverFacts.empty(); + } + return factsFromConcreteTypeName(typeFqn, receiver, enclosing, methodPath, callGraph); + } + + /** + * When receiver is typed as a concrete class (not interface/abstract), prove accessor + * constant from that type alone (covers {@code fire(PayRichEvent e)} without CIC on this hop). + * Instanceof / pattern narrowing is owned by {@link AllocationFactPropagator}. + */ + private ReceiverFacts factsFromConcreteReceiverType( + MethodInvocation accessorCall, + MethodDeclaration enclosing, + List methodPath, + Map> callGraph, + int depth, + Set visited) { + Expression receiver = accessorCall.getExpression(); + if (receiver == null) { + return ReceiverFacts.empty(); + } + String typeFqn = resolveExpressionTypeFqn(receiver, enclosing); + if (typeFqn == null) { + typeFqn = resolveParamTypeFromAst(receiver, enclosing); + } + if (typeFqn == null) { + return ReceiverFacts.empty(); + } + return factsFromConcreteTypeName(typeFqn, receiver, enclosing, methodPath, callGraph); + } + + private ReceiverFacts factsFromConcreteTypeName( + String typeFqn, + Expression receiver, + MethodDeclaration enclosing, + List methodPath, + Map> callGraph) { + TypeDeclaration td = context.getTypeDeclaration(typeFqn); + if (td == null) { + String simple = typeFqn.contains(".") ? typeFqn.substring(typeFqn.lastIndexOf('.') + 1) : typeFqn; + td = context.getTypeDeclaration(simple); + if (td != null) { + typeFqn = context.getFqn(td); + } + } + if (td == null || td.isInterface() || Modifier.isAbstract(td.getModifiers())) { + if (allocationFactPropagator != null) { + return allocationFactPropagator.factsAt(receiver, enclosing, methodPath, callGraph); + } + return ReceiverFacts.empty(); + } + return ReceiverFacts.of(new AllocationSite(typeFqn)); + } + + private List resolveFromValueOfHelper( + MethodInvocation helperCall, + List methodPath, + Map> callGraph, + int depth, + Set visited) { + String key = "helper:" + System.identityHashCode(helperCall); + if (!visited.add(key) || helperCall.arguments().isEmpty()) { + return List.of(); + } + MethodDeclaration helperMd = resolveHelperMethod(helperCall); + if (helperMd == null || helperMd.getBody() == null) { + return List.of(); + } + Expression valueOfArg = extractUniqueValueOfArgument(helperMd); + if (valueOfArg == null) { + return List.of(); + } + Expression mapped = mapHelperValueOfArgToCallerArg(valueOfArg, helperMd, helperCall); + if (mapped != null) { + return resolveUnique(mapped, methodPath, callGraph, depth + 1, visited); + } + return List.of(); + } + + /** + * Map {@code valueOf(x)} inside a helper to the caller argument when {@code x} is the + * parameter (or a local that uniquely aliases the parameter). + */ + private Expression mapHelperValueOfArgToCallerArg( + Expression valueOfArg, + MethodDeclaration helperMd, + MethodInvocation helperCall) { + Expression current = peel(valueOfArg); + for (int hop = 0; hop < MAX_LOCAL_HOPS && current != null; hop++) { + if (current instanceof SimpleName sn) { + int idx = parameterIndex(helperMd, sn.getIdentifier()); + if (idx >= 0 && idx < helperCall.arguments().size()) { + return (Expression) helperCall.arguments().get(idx); + } + Expression init = findLocalDefinition(helperMd, sn.getIdentifier()); + if (init == null || init == current) { + return null; + } + current = peel(init); + continue; + } + return null; + } + return null; + } + + /** + * True when method body uniquely returns {@code Enum.valueOf(x)} (cast/paren peeled; + * {@code x} may be a local assigned from the param). Multiple returns OK when every + * valueOf arg aliases the same helper parameter. + */ + private Expression extractUniqueValueOfArgument(MethodDeclaration method) { + if (method == null || method.getBody() == null) { + return null; + } + List valueOfArgs = new ArrayList<>(); + method.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(ReturnStatement node) { + Expression expr = peel(node.getExpression()); + if (expr instanceof MethodInvocation mi + && "valueOf".equals(mi.getName().getIdentifier()) + && mi.arguments().size() == 1 + && mi.arguments().get(0) instanceof Expression arg) { + valueOfArgs.add(peel(arg)); + } else if (expr instanceof MethodInvocation mi + && "valueOf".equals(mi.getName().getIdentifier()) + && mi.arguments().size() == 2 + && mi.arguments().get(1) instanceof Expression arg) { + valueOfArgs.add(peel(arg)); + } + return super.visit(node); + } + }); + if (valueOfArgs.isEmpty()) { + return null; + } + if (valueOfArgs.size() == 1) { + return valueOfArgs.get(0); + } + String sharedParam = null; + for (Expression arg : valueOfArgs) { + String param = resolveToHelperParamName(arg, method); + if (param == null) { + return null; + } + if (sharedParam == null) { + sharedParam = param; + } else if (!sharedParam.equals(param)) { + return null; + } + } + return valueOfArgs.get(0); + } + + private String resolveToHelperParamName(Expression valueOfArg, MethodDeclaration helperMd) { + Expression current = peel(valueOfArg); + for (int hop = 0; hop < MAX_LOCAL_HOPS && current != null; hop++) { + if (current instanceof SimpleName sn) { + int idx = parameterIndex(helperMd, sn.getIdentifier()); + if (idx >= 0) { + return sn.getIdentifier(); + } + Expression init = findLocalDefinition(helperMd, sn.getIdentifier()); + if (init == null || init == current) { + return null; + } + current = peel(init); + continue; + } + return null; + } + return null; + } + + private List resolveFromIdentityFieldAccess( + Expression receiver, + String fieldName, + List methodPath, + Map> callGraph, + int depth, + Set visited) { + if (receiver == null || fieldName == null || fieldName.isBlank() || constantResolver == null) { + return List.of(); + } + MethodDeclaration enclosing = findEnclosingMethod(receiver); + ReceiverFacts facts = allocationFactPropagator == null + ? ReceiverFacts.empty() + : allocationFactPropagator.factsAt(receiver, enclosing, methodPath, callGraph); + AllocationSite unique = facts.uniqueOrNull(); + String typeFqn = unique != null ? unique.typeFqn() : null; + if (typeFqn == null) { + typeFqn = resolveExpressionTypeFqn(receiver, enclosing); + } + if (typeFqn == null) { + typeFqn = resolveParamTypeFromAst(receiver, enclosing); + } + if (typeFqn == null) { + return List.of(); + } + TypeDeclaration td = context.getTypeDeclaration(typeFqn); + if (td == null) { + String simple = typeFqn.contains(".") ? typeFqn.substring(typeFqn.lastIndexOf('.') + 1) : typeFqn; + td = context.getTypeDeclaration(simple); + } + if (td == null) { + return List.of(); + } + Expression init = findFinalFieldInitializer(td, fieldName); + if (init == null) { + return List.of(); + } + String key = "fieldId:" + context.getFqn(td) + "#" + fieldName; + if (!visited.add(key)) { + return List.of(); + } + return resolveUnique(init, methodPath, callGraph, depth + 1, visited); + } + + private static String resolveParamTypeFromAst(Expression expression, MethodDeclaration enclosing) { + if (!(expression instanceof SimpleName sn) || enclosing == null) { + return null; + } + String name = sn.getIdentifier(); + for (Object paramObj : enclosing.parameters()) { + if (paramObj instanceof SingleVariableDeclaration svd + && name.equals(svd.getName().getIdentifier()) + && svd.getType() != null) { + String type = svd.getType().toString(); + int generic = type.indexOf('<'); + return generic >= 0 ? type.substring(0, generic) : type; + } + } + return InstanceofNarrower.resolvePatternVariableType(name, enclosing); + } + + private static Expression findFinalFieldInitializer(TypeDeclaration td, String fieldName) { + if (td == null || fieldName == null) { + return null; + } + for (FieldDeclaration fd : td.getFields()) { + if (!Modifier.isFinal(fd.getModifiers())) { + continue; + } + for (Object fragObj : fd.fragments()) { + if (fragObj instanceof VariableDeclarationFragment frag + && fieldName.equals(frag.getName().getIdentifier()) + && frag.getInitializer() != null) { + return frag.getInitializer(); + } + } + } + return null; + } + + private MethodDeclaration resolveHelperMethod(MethodInvocation call) { + String methodName = call.getName().getIdentifier(); + TypeDeclaration owner = null; + if (call.getExpression() == null) { + owner = findEnclosingType(call); + } else if (call.getExpression() instanceof SimpleName sn) { + owner = findEnclosingType(call); + TypeDeclaration named = context.getTypeDeclaration(sn.getIdentifier()); + if (named != null) { + owner = named; + } + } else if (call.getExpression() instanceof Name name) { + owner = context.getTypeDeclaration(name.getFullyQualifiedName()); + } + if (owner == null) { + owner = findEnclosingType(call); + } + if (owner == null) { + return null; + } + MethodDeclaration md = context.findMethodDeclaration(owner, methodName, true); + if (md != null) { + return md; + } + String superFqn = context.getSuperclassFqn(owner); + int hops = 0; + while (superFqn != null && hops++ < 8) { + TypeDeclaration superTd = context.getTypeDeclaration(superFqn); + if (superTd == null) { + break; + } + md = context.findMethodDeclaration(superTd, methodName, true); + if (md != null) { + return md; + } + superFqn = context.getSuperclassFqn(superTd); + } + return null; + } + + private boolean looksLikeValueOfHelper(MethodInvocation node) { + if (node.arguments().size() != 1) { + return false; + } + MethodDeclaration md = resolveHelperMethod(node); + if (md == null) { + return false; + } + if (extractUniqueValueOfArgument(md) != null) { + return true; + } + if (MethodInvocationUnwrapper.getReturnedParameterIndex(md) >= 0) { + return true; + } + return !extractStringCaseMap(md).isEmpty(); + } + + private String resolveExpressionTypeFqn(Expression expression, MethodDeclaration enclosing) { + if (expression == null) { + return null; + } + ITypeBinding binding = expression.resolveTypeBinding(); + if (binding != null) { + ITypeBinding erasure = binding.getErasure(); + if (erasure != null && erasure.getQualifiedName() != null && !erasure.getQualifiedName().isBlank()) { + return erasure.getQualifiedName(); + } + } + if (expression instanceof SimpleName sn && enclosing != null && variableTracer != null) { + String methodFqn = methodFqn(enclosing); + if (methodFqn != null) { + String declared = variableTracer.getVariableDeclaredType(methodFqn, sn.getIdentifier()); + if (declared != null && !declared.isBlank()) { + int generic = declared.indexOf('<'); + return generic >= 0 ? declared.substring(0, generic) : declared; + } + } + } + return null; + } + + private static Expression findLocalInitializer(MethodDeclaration method, String varName) { + return findLocalDefinition(method, varName); + } + + /** + * First initializer or assignment RHS for {@code varName} in the method body. + */ + private static Expression findLocalDefinition(MethodDeclaration method, String varName) { + if (method == null || method.getBody() == null || varName == null) { + return null; + } + Expression[] found = {null}; + method.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(VariableDeclarationStatement node) { + for (Object fragObj : node.fragments()) { + if (fragObj instanceof VariableDeclarationFragment frag + && varName.equals(frag.getName().getIdentifier()) + && frag.getInitializer() != null + && found[0] == null) { + found[0] = frag.getInitializer(); + } + } + return super.visit(node); + } + + @Override + public boolean visit(Assignment node) { + if (found[0] == null + && node.getLeftHandSide() instanceof SimpleName lhs + && varName.equals(lhs.getIdentifier())) { + found[0] = node.getRightHandSide(); + } + return super.visit(node); + } + }); + return found[0]; + } + + 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) { + 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 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; + } + + /** + * Peel identity-preserving String ops: trim/strip/toUpperCase/toLowerCase with 0 args. + * Also peels Optional.of / get / orElseThrow (0-arg) wrappers when the receiver is clearly + * an Optional/Mono/Flux factory chain — not {@code Supplier.get()}. + * Locale/{@code replace} stay intact (fail-closed). + */ + private static Expression peelIdentityString(Expression expression) { + Expression current = peel(expression); + boolean progressed; + do { + progressed = false; + if (current instanceof MethodInvocation mi + && mi.getExpression() != null + && mi.arguments().isEmpty() + && IDENTITY_STRING_PEELS.contains(mi.getName().getIdentifier())) { + current = peel(mi.getExpression()); + progressed = true; + continue; + } + if (current instanceof MethodInvocation mi + && mi.arguments().isEmpty() + && ("get".equals(mi.getName().getIdentifier()) + || "orElseThrow".equals(mi.getName().getIdentifier())) + && mi.getExpression() != null + && isIdentityContainerReceiver(mi.getExpression())) { + current = peel(mi.getExpression()); + progressed = true; + continue; + } + if (current instanceof MethodInvocation mi + && "of".equals(mi.getName().getIdentifier()) + && mi.arguments().size() == 1 + && mi.arguments().get(0) instanceof Expression arg) { + String recvText = mi.getExpression() == null ? "" : mi.getExpression().toString(); + if (recvText.endsWith("Optional") || recvText.contains("Optional.")) { + current = peel(arg); + progressed = true; + } + } + } while (progressed); + return current; + } + + private static boolean isIdentityContainerReceiver(Expression receiver) { + if (receiver == null) { + return false; + } + Expression peeled = peel(receiver); + if (peeled instanceof MethodInvocation mi) { + String name = mi.getName().getIdentifier(); + if ("of".equals(name) || "just".equals(name) || "empty".equals(name)) { + return true; + } + if (mi.getExpression() != null && isIdentityContainerReceiver(mi.getExpression())) { + return true; + } + } + String text = peeled.toString(); + return text.contains("Optional") + || text.endsWith("Mono") + || text.endsWith("Flux") + || text.contains("Mono.") + || text.contains("Flux."); + } + + private List resolveFromMessageCarrier( + Expression peeled, + List methodPath, + Map> callGraph, + int depth, + Set visited) { + Expression eventExpr = MessageCarrierSupport.peelEventFromMessageExpr(peeled); + if (eventExpr == null || eventExpr == peeled) { + return List.of(); + } + return resolveUnique(eventExpr, methodPath, callGraph, depth + 1, visited); + } + + private List resolveFromFunctionalGet( + MethodInvocation getCall, + List methodPath, + Map> callGraph, + int depth, + Set visited) { + String key = "fnGet:" + System.identityHashCode(getCall); + if (!visited.add(key) || getCall.getExpression() == null) { + return List.of(); + } + Expression binding = findFunctionalBinding( + peel(getCall.getExpression()), findEnclosingMethod(getCall), methodPath, callGraph); + if (binding instanceof LambdaExpression lambda) { + Expression body = ReactiveExpressionSupport.lambdaBodyExpression(lambda); + if (body != null) { + return resolveUnique(body, methodPath, callGraph, depth + 1, visited); + } + return List.of(); + } + if (binding != null && binding != getCall.getExpression()) { + return resolveUnique(binding, methodPath, callGraph, depth + 1, visited); + } + return List.of(); + } + + private Expression findFunctionalBinding( + Expression receiver, + MethodDeclaration enclosing, + List methodPath, + Map> callGraph) { + Expression peeled = peel(receiver); + if (peeled instanceof LambdaExpression lambda) { + return lambda; + } + if (!(peeled instanceof SimpleName sn) || enclosing == null) { + return null; + } + Expression init = findLocalInitializer(enclosing, sn.getIdentifier()); + if (init != null) { + return peel(init); + } + if (variableTracer != null) { + for (Expression def : variableTracer.traceVariableAll(peeled)) { + Expression defPeeled = peel(def); + if (defPeeled instanceof LambdaExpression) { + return defPeeled; + } + } + } + return findCallerArgumentExpression(sn, enclosing, methodPath, callGraph); + } + + private Expression findCallerArgumentExpression( + SimpleName paramName, + MethodDeclaration enclosing, + List methodPath, + Map> callGraph) { + if (paramName == null || enclosing == null || methodPath == null || callGraph == null) { + return null; + } + int paramIndex = parameterIndex(enclosing, paramName.getIdentifier()); + if (paramIndex < 0) { + return null; + } + String calleeFqn = methodFqn(enclosing); + if (calleeFqn == null) { + return null; + } + int calleeIdx = methodPath.indexOf(calleeFqn); + if (calleeIdx <= 0) { + calleeIdx = indexOfMethodOnPath(methodPath, calleeFqn); + } + if (calleeIdx <= 0) { + return null; + } + String callerMethodFqn = methodPath.get(calleeIdx - 1); + List edges = callGraph.get(callerMethodFqn); + if (edges == null || edges.isEmpty()) { + return null; + } + Expression unique = null; + for (CallEdge edge : edges) { + if (edge == null || edge.getTargetMethod() == null) { + continue; + } + if (!CallSiteArgBinder.methodsMatch(edge.getTargetMethod(), calleeFqn)) { + continue; + } + MethodDeclaration callerMd = findMethodDeclaration(callerMethodFqn); + Expression callerArg = callerMd == null + ? null + : CallSiteArgBinder.findUniqueArgument(callerMd, edge, paramIndex).orElse(null); + if (callerArg == null) { + return null; + } + if (unique == null) { + unique = callerArg; + } else if (unique != callerArg + && !normalizeExpr(unique).equals(normalizeExpr(callerArg))) { + return null; + } + } + return unique; + } + + private static String normalizeExpr(Expression expression) { + return expression == null ? "" : expression.toString().replaceAll("\\s+", ""); + } + + private List resolveFromHeadersGet( + MethodInvocation headerGet, + List methodPath, + Map> callGraph, + int depth, + Set visited) { + String key = stringLiteralValue(headerGet.arguments().get(0)); + if (!LibraryUnwrapRegistry.isEventHeaderKey(key)) { + return List.of(); + } + Expression headersRecv = peel(headerGet.getExpression()); + Expression messageExpr = peelHeadersReceiver(headersRecv); + if (messageExpr == null) { + return List.of(); + } + MethodDeclaration enclosing = findEnclosingMethod(headerGet); + Expression builder = findMessageBuilderInitializer(messageExpr, enclosing); + if (builder == null) { + return List.of(); + } + Expression headerEvent = MessageCarrierSupport.findSetHeaderValueOnChain(builder, key); + if (headerEvent == null) { + return List.of(); + } + return resolveUnique(headerEvent, methodPath, callGraph, depth + 1, visited); + } + + private static Expression peelHeadersReceiver(Expression headersRecv) { + Expression peeled = peel(headersRecv); + if (peeled instanceof MethodInvocation mi + && "getHeaders".equals(mi.getName().getIdentifier()) + && mi.arguments().isEmpty() + && mi.getExpression() != null) { + return peel(mi.getExpression()); + } + return peeled; + } + + private Expression findMessageBuilderInitializer(Expression messageExpr, MethodDeclaration enclosing) { + Expression peeled = peel(messageExpr); + if (peeled instanceof MethodInvocation || peeled instanceof ClassInstanceCreation) { + return peeled; + } + if (!(peeled instanceof SimpleName sn) || enclosing == null || enclosing.getBody() == null) { + return null; + } + Expression init = findLocalInitializer(enclosing, sn.getIdentifier()); + return init == null ? null : peel(init); + } + + private static String stringLiteralValue(Object arg) { + if (arg instanceof StringLiteral sl) { + return sl.getLiteralValue(); + } + return null; + } + + private static MethodInvocation findVirtualAccessorInvocation(Expression expression) { + Expression peeled = peelIdentityString(peel(expression)); + if (peeled instanceof MethodInvocation mi + && isVirtualAccessorName(mi.getName().getIdentifier()) + && mi.getExpression() != null + && mi.arguments().isEmpty()) { + return mi; + } + if (peeled instanceof MethodInvocation nameMi + && "name".equals(nameMi.getName().getIdentifier()) + && nameMi.arguments().isEmpty() + && nameMi.getExpression() != null) { + return findVirtualAccessorInvocation(nameMi.getExpression()); + } + if (peeled instanceof MethodInvocation valueOfMi + && "valueOf".equals(valueOfMi.getName().getIdentifier())) { + if (valueOfMi.arguments().size() == 1 + && valueOfMi.arguments().get(0) instanceof Expression arg) { + return findVirtualAccessorInvocation(arg); + } + if (valueOfMi.arguments().size() == 2 + && valueOfMi.arguments().get(1) instanceof Expression arg) { + return findVirtualAccessorInvocation(arg); + } + } + return null; + } + + 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) { + return CallSiteArgBinder.methodNameOf(methodFqn); + } + + private static int indexOfMethodOnPath(List methodPath, String methodFqn) { + if (methodPath == null || methodFqn == null) { + return -1; + } + String simple = CallSiteArgBinder.methodNameOf(methodFqn); + for (int i = methodPath.size() - 1; i >= 0; i--) { + String hop = methodPath.get(i); + if (hop != null && (hop.equals(methodFqn) || hop.endsWith("." + simple))) { + return i; + } + } + return -1; + } + + private static String cacheKey(Expression expression, List methodPath, int depth) { + if (expression == null) { + return null; + } + // Only cache root-ish peels (depth 0–2) to keep the map small and path-sensitive. + if (depth > 2) { + return null; + } + String pathKey = methodPath == null ? "" : String.join(">", methodPath); + return System.identityHashCode(expression) + "@" + pathKey + "#" + depth; + } + + public static boolean isSendLikeName(String name) { + return name != null && SEND_LIKE_NAMES.contains(name); + } + + public static boolean isVirtualAccessorName(String name) { + if (name == null || name.isBlank()) { + return false; + } + return isEventIdentityAccessorName(name) + || (name.startsWith("get") && name.length() > 3) + || COMPACT_ACCESSOR_NAMES.contains(name); + } + + public static boolean isEventIdentityAccessorName(String name) { + return "getType".equals(name) + || "getEvent".equals(name) + || "getId".equals(name) + || "resolveEventCode".equals(name); + } + + private static boolean looksLikeEnumConstant(String value) { + if (value == null || value.isBlank()) { + return false; + } + String trimmed = value.trim(); + if (trimmed.contains(".")) { + trimmed = trimmed.substring(trimmed.lastIndexOf('.') + 1); + } + return trimmed.matches("[A-Z_][A-Z0-9_]*"); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/LibraryUnwrapRegistry.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/LibraryUnwrapRegistry.java index 5f9c2cd..63d4b3e 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/LibraryUnwrapRegistry.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/LibraryUnwrapRegistry.java @@ -9,7 +9,7 @@ import java.util.Set; public final class LibraryUnwrapRegistry { private static final Set UNWRAP_RECEIVER_TYPES = Set.of( - "Optional", "Stream", "List", "Set", "Arrays", "Objects", "Mono", "Flux"); + "Optional", "Stream", "List", "Set", "Arrays", "Objects", "Mono", "Flux", "MessageBuilder"); private static final Set UNWRAP_METHOD_NAMES = Set.of( "of", "ofEntries", "asList", "entry", "just", "withPayload", "success"); @@ -18,6 +18,9 @@ public final class LibraryUnwrapRegistry { private static final Set REACTIVE_TRANSFORM_METHODS = Set.of("map", "flatMap", "switchMap", "concatMap"); + /** Fixed header keys that may carry a machine event (free-form / dynamic keys stay fail-closed). */ + private static final Set EVENT_HEADER_KEYS = Set.of("event", "smEvent"); + private static final String[] STOP_UNWRAP_PREFIXES = { "map", "parse", "convert", "to", "resolve", "build", "create", "from", "transform", "translate", "derive", "determine", "calculate", "decode", @@ -68,4 +71,20 @@ public final class LibraryUnwrapRegistry { public static boolean isHeaderSetterMethodName(String methodName) { return "setHeader".equals(methodName); } + + public static boolean isEventHeaderKey(String headerKey) { + return headerKey != null && EVENT_HEADER_KEYS.contains(headerKey); + } + + public static boolean isGenericMessageType(String typeName) { + if (typeName == null || typeName.isBlank()) { + return false; + } + String simple = typeName.contains(".") ? typeName.substring(typeName.lastIndexOf('.') + 1) : typeName; + int generic = simple.indexOf('<'); + if (generic >= 0) { + simple = simple.substring(0, generic); + } + return "GenericMessage".equals(simple); + } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/MessageCarrierSupport.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/MessageCarrierSupport.java new file mode 100644 index 0000000..cfec4ad --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/MessageCarrierSupport.java @@ -0,0 +1,102 @@ +package click.kamil.springstatemachineexporter.analysis.pipeline; + +import org.eclipse.jdt.core.dom.CastExpression; +import org.eclipse.jdt.core.dom.ClassInstanceCreation; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.ParenthesizedExpression; +import org.eclipse.jdt.core.dom.StringLiteral; + +/** + * Shared MessageBuilder / GenericMessage / allowlisted-header peels for event identity. + * Used by {@code EventIdentityResolver} and call-graph unwrap paths. + */ +public final class MessageCarrierSupport { + + private MessageCarrierSupport() { + } + + /** + * Prefer allowlisted {@code setHeader("event"|"smEvent", …)} on a MessageBuilder chain; + * else peel {@code withPayload}/{@code just}/{@code success}; else GenericMessage ctor arg. + * + * @return the event-bearing expression, or null when nothing is proven + */ + public static Expression peelEventFromMessageExpr(Expression expression) { + if (expression == null) { + return null; + } + Expression peeled = peel(expression); + Expression headerEvent = findSetHeaderValueOnChain(peeled, null); + if (headerEvent != null) { + return peel(headerEvent); + } + Expression payload = ReactiveExpressionSupport.peelFactoryPayloadExpression(peeled); + if (payload != null) { + return peel(payload); + } + return peelGenericMessageCtor(peeled); + } + + public static Expression peelGenericMessageCtor(Expression expression) { + Expression peeled = peel(expression); + if (peeled instanceof ClassInstanceCreation cic + && LibraryUnwrapRegistry.isGenericMessageType(cic.getType().toString()) + && !cic.arguments().isEmpty() + && cic.arguments().get(0) instanceof Expression arg) { + return peel(arg); + } + return null; + } + + /** + * Walk a MessageBuilder chain for {@code setHeader(key, value)}. When {@code requiredKey} + * is null, returns the first allowlisted event-header value on the chain. + */ + public static Expression findSetHeaderValueOnChain(Expression expression, String requiredKey) { + Expression current = peel(expression); + while (current instanceof MethodInvocation mi) { + if (LibraryUnwrapRegistry.isHeaderSetterMethodName(mi.getName().getIdentifier()) + && mi.arguments().size() >= 2) { + String key = stringLiteralValue(mi.arguments().get(0)); + if (requiredKey != null) { + if (requiredKey.equals(key) && mi.arguments().get(1) instanceof Expression value) { + return value; + } + } else if (LibraryUnwrapRegistry.isEventHeaderKey(key) + && mi.arguments().get(1) instanceof Expression value) { + return value; + } + } + if ("build".equals(mi.getName().getIdentifier()) && mi.arguments().isEmpty() + && mi.getExpression() != null) { + current = peel(mi.getExpression()); + continue; + } + if (mi.getExpression() instanceof MethodInvocation next) { + current = next; + } else { + break; + } + } + return null; + } + + private static String stringLiteralValue(Object arg) { + if (arg instanceof StringLiteral sl) { + return sl.getLiteralValue(); + } + return null; + } + + private static Expression peel(Expression expression) { + Expression current = expression; + while (current instanceof ParenthesizedExpression pe) { + current = pe.getExpression(); + } + if (current instanceof CastExpression ce) { + return peel(ce.getExpression()); + } + return current; + } +} 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 748633d..2020cac 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 @@ -2,8 +2,8 @@ package click.kamil.springstatemachineexporter.analysis.service; import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.flow.alloc.AllocationFactPropagator; -import click.kamil.springstatemachineexporter.analysis.flow.alloc.ReceiverFacts; import click.kamil.springstatemachineexporter.analysis.flow.alloc.VirtualAccessorConstResolver; +import click.kamil.springstatemachineexporter.analysis.flow.eventid.EventIdentityResolver; import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver; import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder; @@ -39,6 +39,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { protected final PathBindingEvaluator pathBindingEvaluator; protected final AllocationFactPropagator allocationFactPropagator; protected final VirtualAccessorConstResolver virtualAccessorConstResolver; + protected final EventIdentityResolver eventIdentityResolver; private final Map parsedNodeCache = new HashMap<>(); private final Map> polymorphicCallCache = new HashMap<>(); private final Map> pathBindingsCache = new HashMap<>(); @@ -84,6 +85,12 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { context, variableTracer, constructorAnalyzer); this.virtualAccessorConstResolver = new VirtualAccessorConstResolver( context, accessorResolver, constantResolver); + this.eventIdentityResolver = new EventIdentityResolver( + context, + allocationFactPropagator, + virtualAccessorConstResolver, + variableTracer, + constantResolver); } public List findChains(List entryPoints, List triggers) { @@ -96,6 +103,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { pathBindingEvaluator.clearAnalysisCaches(); pathBindingsCache.clear(); parsedNodeCache.clear(); + if (eventIdentityResolver != null) { + eventIdentityResolver.clearSessionCache(); + } Map> callGraph = buildCallGraph(); List chains = new ArrayList<>(); @@ -1332,8 +1342,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, polymorphicEvents); } // valueOf(e.getType()) may have been collapsed to on the sendEvent - // edge — recover via allocation-sensitive getType on the dispatcher frame. - List recovered = recoverAllocationSensitiveAccessorFromPath(path, callGraph); + // edge — recover via event-identity resolution on the dispatcher frame. + List recovered = eventIdentityResolver != null + ? eventIdentityResolver.recoverFromPath(path, callGraph != null ? callGraph : this.graph) + : List.of(); if (recovered.size() == 1) { return buildTriggerPointWithPolymorphicEvents( tp, @@ -1688,9 +1700,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } /** - * When a virtual accessor ({@code getType}/{@code getEvent}/…) has a receiver proven to be a - * concrete {@code new T()}, resolve only that override's compile-time constant instead of - * unioning all interface implementors. + * When a virtual accessor ({@code getType}/{@code getA}/…) has a receiver proven to be a + * concrete {@code new T()} (or concrete declared type), resolve only that override's + * compile-time constant instead of unioning all interface implementors. * * @return true when a unique constant was proven and written into {@code polymorphicEvents} */ @@ -1700,20 +1712,20 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { List path, Map> callGraph, List polymorphicEvents) { - if (!(exprNode instanceof Expression expression) - || allocationFactPropagator == null - || virtualAccessorConstResolver == null) { + if (!(exprNode instanceof Expression expression) || eventIdentityResolver == null) { return false; } - MethodInvocation accessorCall = findVirtualAccessorInvocation(expression, methodName); - if (accessorCall == null) { - return false; - } - String accessorName = accessorCall.getName().getIdentifier(); Map> graphForFacts = callGraph != null ? callGraph : this.graph; - ReceiverFacts facts = allocationFactPropagator.factsForAccessorReceiver( - accessorCall, path, graphForFacts); - List unique = virtualAccessorConstResolver.resolveUniqueConstant(facts, accessorName); + List unique = eventIdentityResolver.resolveUnique(expression, path, graphForFacts); + if (unique.size() != 1) { + // Prefer peeling a nested accessor when methodName hints at get* + if (methodName != null && EventIdentityResolver.isVirtualAccessorName(methodName)) { + MethodInvocation accessorCall = findVirtualAccessorInvocation(expression, methodName); + if (accessorCall != null && accessorCall != expression) { + unique = eventIdentityResolver.resolveUnique(accessorCall, path, graphForFacts); + } + } + } if (unique.size() != 1) { return false; } @@ -1733,7 +1745,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } if (peeled instanceof MethodInvocation mi) { String name = mi.getName().getIdentifier(); - if (preferredName != null && preferredName.equals(name) && isVirtualAccessorName(name)) { + if (preferredName != null && preferredName.equals(name) + && EventIdentityResolver.isVirtualAccessorName(name)) { return mi; } if ("valueOf".equals(name) && mi.arguments().size() == 1 @@ -1743,10 +1756,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return nested; } } - if (preferredName == null && isVirtualAccessorName(name)) { + if (preferredName == null && EventIdentityResolver.isVirtualAccessorName(name)) { return mi; } - if (isVirtualAccessorName(name)) { + if (EventIdentityResolver.isVirtualAccessorName(name)) { return mi; } } @@ -1754,21 +1767,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } private static boolean isVirtualAccessorName(String name) { - if (name == null || name.isBlank()) { - return false; - } - return isEventIdentityAccessorName(name) - || (name.startsWith("get") && name.length() > 3); + return EventIdentityResolver.isVirtualAccessorName(name); } - /** - * Accessors that identify the machine event (not arbitrary getters on the same frame). - */ private static boolean isEventIdentityAccessorName(String name) { - return "getType".equals(name) - || "getEvent".equals(name) - || "getId".equals(name) - || "resolveEventCode".equals(name); + return EventIdentityResolver.isEventIdentityAccessorName(name); } /** @@ -1798,7 +1801,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { List path, Map> callGraph, List polymorphicEvents) { - List recovered = recoverAllocationSensitiveAccessorFromPath(path, callGraph); + if (eventIdentityResolver == null) { + return false; + } + Map> graphForFacts = callGraph != null ? callGraph : this.graph; + List recovered = eventIdentityResolver.recoverFromPath(path, graphForFacts); if (recovered.size() != 1) { return false; } @@ -1807,91 +1814,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return true; } - /** - * When sendEvent's call-graph argument was collapsed to {@code }, 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) { @@ -2296,6 +2218,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { resolvedValue, methodSuffix); List searchMethods = buildGetterSearchMethods(resolvedValue, entryMethod, path); + // Prefer allocation-/identity-sensitive proof before CHA accessor union. + List viaEir = resolveGetterConstantsViaEventIdentity( + expressionText, searchMethods, path, callGraph); + if (viaEir.size() == 1) { + return viaEir; + } + List constants = resolveGetterConstantsViaDataflow(expressionText, searchMethods, path, callGraph, entryMethod); if (!constants.isEmpty()) { return constants; @@ -2305,6 +2234,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { String remapped = ReactiveExpressionSupport.remapLambdaParameterGetterInMethod( expressionText, scopeMethod, constantResolver, context); if (remapped != null && !remapped.equals(expressionText)) { + viaEir = resolveGetterConstantsViaEventIdentity(remapped, searchMethods, path, callGraph); + if (viaEir.size() == 1) { + return viaEir; + } constants = resolveGetterConstantsViaDataflow(remapped, searchMethods, path, callGraph, entryMethod); if (!constants.isEmpty()) { return constants; @@ -2314,6 +2247,23 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return resolveGetterConstantsViaAccessorPipeline(resolvedValue, methodSuffix, entryMethod, path); } + private List resolveGetterConstantsViaEventIdentity( + String expressionText, + List searchMethods, + List path, + Map> callGraph) { + if (eventIdentityResolver == null || expressionText == null || expressionText.isBlank()) { + return List.of(); + } + Expression anchored = click.kamil.springstatemachineexporter.ast.common.AstUtils.findExpressionInMethods( + searchMethods, expressionText, context); + if (anchored == null) { + return List.of(); + } + Map> graphForFacts = callGraph != null ? callGraph : this.graph; + return eventIdentityResolver.resolveUnique(anchored, path, graphForFacts); + } + private List resolveGetterConstantsViaDataflow( String expressionText, List searchMethods, diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java index 3853aa2..2d9ff09 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java @@ -1,5 +1,6 @@ package click.kamil.springstatemachineexporter.analysis.service; +import click.kamil.springstatemachineexporter.analysis.pipeline.MessageCarrierSupport; import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport; import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; @@ -13,6 +14,9 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine { public JdtCallGraphEngine(CodebaseContext context, InjectionPointAnalyzer injectionAnalyzer) { super(context); this.injectionAnalyzer = injectionAnalyzer; + if (allocationFactPropagator != null) { + allocationFactPropagator.setInjectionAnalyzer(injectionAnalyzer); + } } @Override @@ -175,7 +179,11 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine { } private Expression unwrapMessageBuilder(Expression expr) { - Expression peeled = ReactiveExpressionSupport.peelFactoryPayloadExpression(expr); - return peeled != null ? peeled : expr; + Expression peeled = MessageCarrierSupport.peelEventFromMessageExpr(expr); + if (peeled != null) { + return peeled; + } + Expression factory = ReactiveExpressionSupport.peelFactoryPayloadExpression(expr); + return factory != null ? factory : expr; } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/CallSiteArgBinderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/CallSiteArgBinderTest.java new file mode 100644 index 0000000..423a106 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/CallSiteArgBinderTest.java @@ -0,0 +1,99 @@ +package click.kamil.springstatemachineexporter.analysis.flow; + +import click.kamil.springstatemachineexporter.analysis.model.CallEdge; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class CallSiteArgBinderTest { + + @Test + void uniqueSiteWithoutEdgeText(@TempDir Path tempDir) throws Exception { + MethodDeclaration caller = scanCaller(tempDir, """ + package com.example; + class C { + void fire(String a) { route(a); } + void route(String code) {} + } + """); + CallEdge edge = new CallEdge("com.example.C.route", List.of("a")); + Optional arg = CallSiteArgBinder.findUniqueArgument(caller, edge, 0); + assertThat(arg).isPresent(); + assertThat(arg.get().toString()).isEqualTo("a"); + } + + @Test + void multiSiteUsesEdgeText(@TempDir Path tempDir) throws Exception { + MethodDeclaration caller = scanCaller(tempDir, """ + package com.example; + class C { + void fire(String a, String b) { + route(a); + route(b); + } + void route(String code) {} + } + """); + CallEdge edge = new CallEdge("com.example.C.route", List.of("b")); + Optional arg = CallSiteArgBinder.findUniqueArgument(caller, edge, 0); + assertThat(arg).isPresent(); + assertThat(arg.get().toString()).isEqualTo("b"); + } + + @Test + void multiSiteIdenticalTextPicksFirst(@TempDir Path tempDir) throws Exception { + MethodDeclaration caller = scanCaller(tempDir, """ + package com.example; + class C { + void fire(String a) { + route(a); + route(a); + } + void route(String code) {} + } + """); + CallEdge edge = new CallEdge("com.example.C.route", List.of("a")); + Optional arg = CallSiteArgBinder.findUniqueArgument(caller, edge, 0); + assertThat(arg).isPresent(); + assertThat(arg.get().toString()).isEqualTo("a"); + } + + @Test + void multiSiteWithoutEdgeTextFailsClosed(@TempDir Path tempDir) throws Exception { + MethodDeclaration caller = scanCaller(tempDir, """ + package com.example; + class C { + void fire(String a, String b) { + route(a); + route(b); + } + void route(String code) {} + } + """); + CallEdge edge = new CallEdge("com.example.C.route", null); + assertThat(CallSiteArgBinder.findUniqueArgument(caller, edge, 0)).isEmpty(); + } + + private static MethodDeclaration scanCaller(Path tempDir, String source) throws Exception { + Files.writeString(tempDir.resolve("C.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + TypeDeclaration td = context.getTypeDeclaration("com.example.C"); + if (td == null) { + td = context.getTypeDeclaration("C"); + } + assertThat(td).isNotNull(); + MethodDeclaration fire = context.findMethodDeclaration(td, "fire", true); + assertThat(fire).isNotNull(); + return fire; + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/InstanceofNarrowerTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/InstanceofNarrowerTest.java new file mode 100644 index 0000000..1fc18b5 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/flow/alloc/InstanceofNarrowerTest.java @@ -0,0 +1,152 @@ +package click.kamil.springstatemachineexporter.analysis.flow.alloc; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.ASTVisitor; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class InstanceofNarrowerTest { + + @Test + void patternVariableProvesConcrete(@TempDir Path tempDir) throws Exception { + Fixture f = scan(tempDir, """ + package com.example; + class C { + void fire(Object o) { + if (o instanceof PayRichEvent p) { + use(p.getA()); + } + } + void use(String s) {} + } + class PayRichEvent { String getA() { return "PAY"; } } + class ShipRichEvent { String getA() { return "SHIP"; } } + """); + MethodInvocation getA = findInvocation(f.fire, "getA"); + ReceiverFacts facts = InstanceofNarrower.factsAt(getA.getExpression(), getA, f.context); + assertThat(facts.uniqueOrNull()).isNotNull(); + assertThat(facts.uniqueOrNull().typeFqn()).contains("PayRichEvent"); + } + + @Test + void thenBranchClassicInstanceofProvesConcrete(@TempDir Path tempDir) throws Exception { + Fixture f = scan(tempDir, """ + package com.example; + class C { + void fire(Object o) { + if (o instanceof PayRichEvent) { + use(((PayRichEvent) o).getA()); + } + } + void use(String s) {} + } + class PayRichEvent { String getA() { return "PAY"; } } + class ShipRichEvent { String getA() { return "SHIP"; } } + """); + MethodInvocation getA = findInvocation(f.fire, "getA"); + ReceiverFacts facts = InstanceofNarrower.factsAt(getA.getExpression(), getA, f.context); + assertThat(facts.uniqueOrNull()).isNotNull(); + assertThat(facts.uniqueOrNull().typeFqn()).contains("PayRichEvent"); + } + + @Test + void outsideThenStaysEmpty(@TempDir Path tempDir) throws Exception { + Fixture f = scan(tempDir, """ + package com.example; + class C { + void fire(Object o) { + if (o instanceof PayRichEvent p) {} + use(((PayRichEvent) o).getA()); + } + void use(String s) {} + } + class PayRichEvent { String getA() { return "PAY"; } } + """); + MethodInvocation getA = findInvocation(f.fire, "getA"); + ReceiverFacts facts = InstanceofNarrower.factsAt(getA.getExpression(), getA, f.context); + assertThat(facts.isEmpty()).isTrue(); + } + + @Test + void refineObjectCicWithInstanceof(@TempDir Path tempDir) throws Exception { + Fixture f = scan(tempDir, """ + package com.example; + class C { + void fire(Object o) { + if (o instanceof PayRichEvent) { + use(((PayRichEvent) o).getA()); + } + } + void use(String s) {} + } + class PayRichEvent { String getA() { return "PAY"; } } + """); + MethodInvocation getA = findInvocation(f.fire, "getA"); + ReceiverFacts base = ReceiverFacts.of(new AllocationSite("java.lang.Object")); + ReceiverFacts refined = InstanceofNarrower.refine(base, getA.getExpression(), getA, f.context); + assertThat(refined.uniqueOrNull().typeFqn()).contains("PayRichEvent"); + } + + @Test + void refineConcreteConflictFailsClosed(@TempDir Path tempDir) throws Exception { + Fixture f = scan(tempDir, """ + package com.example; + class C { + void fire(Object o) { + if (o instanceof PayRichEvent) { + use(((PayRichEvent) o).getA()); + } + } + void use(String s) {} + } + class PayRichEvent { String getA() { return "PAY"; } } + class ShipRichEvent { String getA() { return "SHIP"; } } + """); + MethodInvocation getA = findInvocation(f.fire, "getA"); + ReceiverFacts base = ReceiverFacts.of(new AllocationSite("com.example.ShipRichEvent")); + ReceiverFacts refined = InstanceofNarrower.refine(base, getA.getExpression(), getA, f.context); + assertThat(refined.isEmpty()).isTrue(); + } + + private static Fixture scan(Path tempDir, String source) throws Exception { + Files.writeString(tempDir.resolve("C.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + TypeDeclaration td = context.getTypeDeclaration("com.example.C"); + if (td == null) { + td = context.getTypeDeclaration("C"); + } + assertThat(td).isNotNull(); + MethodDeclaration fire = context.findMethodDeclaration(td, "fire", true); + assertThat(fire).isNotNull(); + return new Fixture(context, fire); + } + + private static MethodInvocation findInvocation(MethodDeclaration method, String name) { + MethodInvocation[] found = {null}; + method.accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if (name.equals(node.getName().getIdentifier())) { + found[0] = node; + return false; + } + return super.visit(node); + } + }); + assertThat(found[0]).as("expected invocation %s", name).isNotNull(); + return found[0]; + } + + private record Fixture(CodebaseContext context, MethodDeclaration fire) { + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/MessageCarrierSupportTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/MessageCarrierSupportTest.java new file mode 100644 index 0000000..80f86cd --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/MessageCarrierSupportTest.java @@ -0,0 +1,133 @@ +package click.kamil.springstatemachineexporter.analysis.pipeline; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.ASTVisitor; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class MessageCarrierSupportTest { + + @Test + void peelsWithPayload(@TempDir Path tempDir) throws Exception { + Expression sendArg = sendArg(tempDir, """ + package com.example; + class C { + void fire() { + send(MessageBuilder.withPayload(OrderEvent.PAY).build()); + } + void send(Object e) {} + } + enum OrderEvent { PAY } + class MessageBuilder { + static MessageBuilder withPayload(Object e) { return new MessageBuilder(); } + Object build() { return null; } + } + """); + Expression peeled = MessageCarrierSupport.peelEventFromMessageExpr(sendArg); + assertThat(peeled).isNotNull(); + assertThat(peeled.toString()).contains("PAY"); + } + + @Test + void prefersAllowlistedHeaderOverPayload(@TempDir Path tempDir) throws Exception { + Expression sendArg = sendArg(tempDir, """ + package com.example; + class C { + void fire() { + Object dto = new Object(); + send(MessageBuilder.withPayload(dto).setHeader("event", OrderEvent.PAY).build()); + } + void send(Object e) {} + } + enum OrderEvent { PAY } + class MessageBuilder { + static MessageBuilder withPayload(Object e) { return new MessageBuilder(); } + MessageBuilder setHeader(String k, Object v) { return this; } + Object build() { return null; } + } + """); + Expression peeled = MessageCarrierSupport.peelEventFromMessageExpr(sendArg); + assertThat(peeled).isNotNull(); + assertThat(peeled.toString()).contains("PAY"); + assertThat(MessageCarrierSupport.findSetHeaderValueOnChain(sendArg, "event").toString()) + .contains("PAY"); + } + + @Test + void freeFormHeaderKeyIgnored(@TempDir Path tempDir) throws Exception { + Expression sendArg = sendArg(tempDir, """ + package com.example; + class C { + void fire() { + Object dto = new Object(); + send(MessageBuilder.withPayload(dto).setHeader("customKey", OrderEvent.PAY).build()); + } + void send(Object e) {} + } + enum OrderEvent { PAY } + class MessageBuilder { + static MessageBuilder withPayload(Object e) { return new MessageBuilder(); } + MessageBuilder setHeader(String k, Object v) { return this; } + Object build() { return null; } + } + """); + assertThat(MessageCarrierSupport.findSetHeaderValueOnChain(sendArg, null)).isNull(); + Expression peeled = MessageCarrierSupport.peelEventFromMessageExpr(sendArg); + // Falls through to dto payload — not the header const. + assertThat(peeled).isNotNull(); + assertThat(peeled.toString()).doesNotContain("PAY"); + } + + @Test + void peelsGenericMessageCtor(@TempDir Path tempDir) throws Exception { + Expression sendArg = sendArg(tempDir, """ + package com.example; + class C { + void fire() { + send(new GenericMessage<>(OrderEvent.PAY)); + } + void send(Object e) {} + } + enum OrderEvent { PAY } + class GenericMessage { GenericMessage(T payload) {} } + """); + Expression peeled = MessageCarrierSupport.peelEventFromMessageExpr(sendArg); + assertThat(peeled).isNotNull(); + assertThat(peeled.toString()).contains("PAY"); + } + + private static Expression sendArg(Path tempDir, String source) throws Exception { + Files.writeString(tempDir.resolve("C.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + TypeDeclaration td = context.getTypeDeclaration("com.example.C"); + if (td == null) { + td = context.getTypeDeclaration("C"); + } + assertThat(td).isNotNull(); + MethodDeclaration fire = context.findMethodDeclaration(td, "fire", true); + assertThat(fire).isNotNull(); + MethodInvocation[] send = {null}; + fire.accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if ("send".equals(node.getName().getIdentifier()) && !node.arguments().isEmpty()) { + send[0] = node; + return false; + } + return super.visit(node); + } + }); + assertThat(send[0]).isNotNull(); + return (Expression) send[0].arguments().get(0); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/BeanAsAllocEventIdentityTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/BeanAsAllocEventIdentityTest.java new file mode 100644 index 0000000..33929c1 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/BeanAsAllocEventIdentityTest.java @@ -0,0 +1,181 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher; +import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.LinkResolution; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer; +import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry; +import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner; +import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import click.kamil.springstatemachineexporter.model.Event; +import click.kamil.springstatemachineexporter.model.State; +import click.kamil.springstatemachineexporter.model.Transition; +import org.eclipse.jdt.core.dom.CompilationUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unique {@code @Component} implementing an interface field acts as CIC substitute for EIR. + */ +class BeanAsAllocEventIdentityTest { + + @Test + void uniqueComponentBeanAsAllocResolves(@TempDir Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("App.java"), """ + package com.example; + import org.springframework.stereotype.Component; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(); } + } + class Dispatcher { + StateMachine machine; + RichEvent carrier; + void fire() { + machine.sendEvent(OrderEvent.valueOf(carrier.getA())); + } + } + interface RichEvent { String getA(); } + @Component + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + class StateMachine { public void sendEvent(OrderEvent e) {} } + enum OrderEvent { PAY, SHIP, CANCEL } + enum OrderState { NEW, PAID, SHIPPED, CANCELLED } + class OrderStateMachineConfig {} + """); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.setClasspath(ToolingClasspath.currentJvmJarEntries()); + context.scan(Set.of(tempDir), Set.of()); + + SpringBeanRegistry registry = new SpringBeanRegistry(); + for (CompilationUnit cu : context.getCompilationUnits()) { + cu.accept(new SpringContextScanner(registry)); + } + InjectionPointAnalyzer injectionAnalyzer = + new InjectionPointAnalyzer(new SpringDependencyResolver(registry)); + JdtCallGraphEngine engine = new JdtCallGraphEngine(context, injectionAnalyzer); + + EntryPoint entry = EntryPoint.builder() + .className("com.example.ApiController") + .methodName("pay") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("e") + .build(); + List chains = engine.findChains(List.of(entry), List.of(trigger)); + assertThat(chains).hasSize(1); + + Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY"); + Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP"); + AnalysisResult result = AnalysisResult.builder() + .name("com.example.OrderStateMachineConfig") + .transitions(List.of(pay, ship)) + .metadata(CodebaseMetadata.builder().callChains(chains).build()) + .build(); + new TransitionLinkerEnricher().enrich(result, null, null); + CallChain linked = result.getMetadata().getCallChains().get(0); + assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED); + assertThat(linked.getMatchedTransitions()).hasSize(1); + assertThat(linked.getMatchedTransitions().get(0).getEvent()) + .isEqualTo("com.example.OrderEvent.PAY"); + } + + @Test + void multiComponentBeanStaysFailClosed(@TempDir Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("App.java"), """ + package com.example; + import org.springframework.stereotype.Component; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(); } + } + class Dispatcher { + StateMachine machine; + RichEvent carrier; + void fire() { + machine.sendEvent(OrderEvent.valueOf(carrier.getA())); + } + } + interface RichEvent { String getA(); } + @Component + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + @Component + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + class StateMachine { public void sendEvent(OrderEvent e) {} } + enum OrderEvent { PAY, SHIP, CANCEL } + enum OrderState { NEW, PAID, SHIPPED, CANCELLED } + class OrderStateMachineConfig {} + """); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.setClasspath(ToolingClasspath.currentJvmJarEntries()); + context.scan(Set.of(tempDir), Set.of()); + + SpringBeanRegistry registry = new SpringBeanRegistry(); + for (CompilationUnit cu : context.getCompilationUnits()) { + cu.accept(new SpringContextScanner(registry)); + } + InjectionPointAnalyzer injectionAnalyzer = + new InjectionPointAnalyzer(new SpringDependencyResolver(registry)); + JdtCallGraphEngine engine = new JdtCallGraphEngine(context, injectionAnalyzer); + + EntryPoint entry = EntryPoint.builder() + .className("com.example.ApiController") + .methodName("pay") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("e") + .build(); + List chains = engine.findChains(List.of(entry), List.of(trigger)); + assertThat(chains).hasSize(1); + + Transition pay = transition("NEW", "PAID", "com.example.OrderEvent.PAY"); + Transition ship = transition("PAID", "SHIPPED", "com.example.OrderEvent.SHIP"); + AnalysisResult result = AnalysisResult.builder() + .name("com.example.OrderStateMachineConfig") + .transitions(List.of(pay, ship)) + .metadata(CodebaseMetadata.builder().callChains(chains).build()) + .build(); + new TransitionLinkerEnricher().enrich(result, null, null); + CallChain linked = result.getMetadata().getCallChains().get(0); + assertThat(linked.getMatchedTransitions()).isNullOrEmpty(); + assertThat(linked.getLinkResolution()) + .isIn(LinkResolution.AMBIGUOUS_WIDEN, LinkResolution.NO_MATCH, LinkResolution.UNRESOLVED_EXTERNAL); + } + + private static Transition transition(String source, String target, String eventFqn) { + Transition transition = new Transition(); + transition.setSourceStates(List.of(State.of(source, source))); + transition.setTargetStates(List.of(State.of(target, target))); + transition.setEvent(Event.of(eventFqn, eventFqn)); + return transition; + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventIdentityResolverPipelineTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventIdentityResolverPipelineTest.java new file mode 100644 index 0000000..295a67c --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventIdentityResolverPipelineTest.java @@ -0,0 +1,1251 @@ +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; + +/** + * Event-identity resolver matrix: getA + local + protected valueOf helper, concrete param, etc. + */ +class EventIdentityResolverPipelineTest { + + 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 = """ + class StateMachine { public void sendEvent(OrderEvent e) {} } + enum OrderEvent { PAY, SHIP, CANCEL } + enum OrderState { NEW, PAID, SHIPPED, CANCELLED } + class OrderStateMachineConfig {} + """; + + @Test + void getALocalAndProtectedValueOfHelper(@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; + protected OrderEvent toEvent(String event) { + return OrderEvent.valueOf(event); + } + void fire(RichEvent e) { + String event = e.getA(); + machine.sendEvent(toEvent(event)); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void sendEventDirectToEventGetA(@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; + protected OrderEvent toEvent(String event) { + return OrderEvent.valueOf(event); + } + void fire(RichEvent e) { + machine.sendEvent(toEvent(e.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void concreteParamTypeWithoutCicOnHop(@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(PayRichEvent e) { + machine.sendEvent(OrderEvent.valueOf(e.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void valueOfLocalFromGetA(@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) { + String event = e.getA(); + machine.sendEvent(OrderEvent.valueOf(event)); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void crossMethodStringParamForwardsGetA(@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) { + route(e.getA()); + } + void route(String code) { + machine.sendEvent(OrderEvent.valueOf(code)); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void valueOfHelperWithIntermediateLocal(@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; + protected OrderEvent toEvent(String event) { + String code = event; + return OrderEvent.valueOf(code); + } + void fire(RichEvent e) { + machine.sendEvent(toEvent(e.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void abstractSuperclassProtectedToEvent(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(new PayRichEvent()); } + } + abstract class AbstractDispatcher { + StateMachine machine; + protected OrderEvent toEvent(String event) { + String code = (String) event; + return OrderEvent.valueOf(code); + } + } + class Dispatcher extends AbstractDispatcher { + void fire(RichEvent e) { + machine.sendEvent(toEvent(e.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void machineFireRecoveryWithToEventGetA(@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; + protected OrderEvent toEvent(String event) { + return OrderEvent.valueOf(event); + } + void fire(RichEvent e) { + machine.fire(toEvent(e.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + class StateMachine { public void fire(OrderEvent e) {} } + enum OrderEvent { PAY, SHIP, CANCEL } + enum OrderState { NEW, PAID, SHIPPED, CANCELLED } + class OrderStateMachineConfig {} + """; + assertResolvedPay(source, tempDir, "fire"); + } + + @Test + void fieldStoreThenFlushResolves(@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; + RichEvent carrier; + void fire(RichEvent e) { + this.carrier = e; + machine.sendEvent(OrderEvent.valueOf(carrier.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void setterWrapperGetEventResolves(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(); } + } + class Dispatcher { + StateMachine machine; + void fire() { + EventWrapper w = new EventWrapper(); + w.setEvent(new PayRichEvent()); + machine.sendEvent(OrderEvent.valueOf(w.getEvent().getA())); + } + } + class EventWrapper { + RichEvent event; + void setEvent(RichEvent e) { this.event = e; } + RichEvent getEvent() { return event; } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void valueOfTrimAndToUpperCaseResolves(@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) { + String code = e.getA().trim().toUpperCase(); + machine.sendEvent(OrderEvent.valueOf(code)); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void toUpperCaseWithLocaleStaysFailClosed(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import java.util.Locale; + 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.getA().toUpperCase(Locale.ROOT))); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + 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); + // Locale arg must not peel; CHA may widen — never unique RESOLVED through Locale peel. + if (linked.getLinkResolution() == LinkResolution.RESOLVED) { + assertThat(linked.getMatchedTransitions()).isNullOrEmpty(); + } else { + assertThat(linked.getLinkResolution()) + .isIn(LinkResolution.AMBIGUOUS_WIDEN, LinkResolution.NO_MATCH, LinkResolution.UNRESOLVED_EXTERNAL); + } + } + + @Test + void multiReturnSameParamValueOfHelper(@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; + protected OrderEvent toEvent(String event) { + try { + return OrderEvent.valueOf(event); + } finally { + // second return path still valueOf(same param) after alias + } + } + protected OrderEvent map(String event) { + if (event == null) { + return OrderEvent.valueOf(event); + } + return OrderEvent.valueOf(event); + } + void fire(RichEvent e) { + machine.sendEvent(map(e.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void compactTypeAccessorResolves(@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.type())); + } + } + interface RichEvent { String type(); } + class PayRichEvent implements RichEvent { + public String type() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String type() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void publicFinalIdentityFieldResolves(@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(PayRichEvent e) { + machine.sendEvent(OrderEvent.valueOf(e.a)); + } + } + class PayRichEvent { + public final String a = "PAY"; + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void valueOfAccessorNamePeelResolves(@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(PayRichEvent e) { + machine.sendEvent(OrderEvent.valueOf(e.getType().name())); + } + } + class PayRichEvent { + public OrderEvent getType() { return OrderEvent.PAY; } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void twoArgEnumValueOfResolves(@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(Enum.valueOf(OrderEvent.class, e.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void triggerRecoveryResolves(@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.trigger(OrderEvent.valueOf(e.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + class StateMachine { public void trigger(OrderEvent e) {} } + enum OrderEvent { PAY, SHIP, CANCEL } + enum OrderState { NEW, PAID, SHIPPED, CANCELLED } + class OrderStateMachineConfig {} + """; + assertResolvedPay(source, tempDir, "trigger"); + } + + @Test + void passthroughAssertHelperResolves(@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; + OrderEvent assertSupported(OrderEvent e) { return e; } + void fire(RichEvent e) { + machine.sendEvent(assertSupported(OrderEvent.valueOf(e.getA()))); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void fromCodeSwitchMapResolves(@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; + OrderEvent fromCode(String code) { + switch (code) { + case "PAY": return OrderEvent.PAY; + case "SHIP": return OrderEvent.SHIP; + case "CANCEL": return OrderEvent.CANCEL; + default: throw new IllegalArgumentException(code); + } + } + void fire(RichEvent e) { + machine.sendEvent(fromCode(e.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return "PAY"; } + } + class ShipRichEvent implements RichEvent { + public String getA() { return "SHIP"; } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void fromCodeIncompleteMapStaysFailClosed(@TempDir Path tempDir) throws Exception { + // Map has no branch for PAY; helper must not invent a constant (and has no enum returns for CHA). + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(new PayRichEvent()); } + } + class Dispatcher { + StateMachine machine; + OrderEvent fromCode(String code) { + if ("OTHER".equals(code)) { + throw new IllegalArgumentException("other"); + } + throw new IllegalArgumentException(code); + } + void fire(RichEvent e) { + machine.sendEvent(fromCode(e.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return "PAY"; } + } + class ShipRichEvent implements RichEvent { + public String getA() { return "SHIP"; } + } + """ + TAIL; + assertFailClosed(source, tempDir, "pay"); + } + + @Test + void ternarySameConstBothArmsResolves(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(); } + } + class Dispatcher { + StateMachine machine; + boolean flag; + void fire() { + machine.sendEvent(flag ? OrderEvent.PAY : OrderEvent.PAY); + } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void ternaryDifferentArmsStaysFailClosed(@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; + boolean flag; + void fire(RichEvent e) { + machine.sendEvent(flag ? OrderEvent.PAY : OrderEvent.SHIP); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertFailClosed(source, tempDir, "pay"); + } + + @Test + void deepStringHopsResolves(@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) { + route(e.getA()); + } + void route(String code) { + dispatch(code); + } + void dispatch(String code) { + machine.sendEvent(OrderEvent.valueOf(code)); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void optionalOfGetPeelResolves(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import java.util.Optional; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(new PayRichEvent()); } + } + class Dispatcher { + StateMachine machine; + void fire(RichEvent e) { + machine.sendEvent(OrderEvent.valueOf(Optional.of(e).get().getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void renameGetCodeAndMapEventStillResolves(@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; + protected OrderEvent mapEvent(String event) { + return OrderEvent.valueOf(event); + } + void fire(RichEvent e) { + String code = e.getCode(); + machine.sendEvent(mapEvent(code)); + } + } + interface RichEvent { String getCode(); } + class PayRichEvent implements RichEvent { + public String getCode() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getCode() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void dualRouteIdenticalArgsStillResolves(@TempDir Path tempDir) throws Exception { + // Two AST sites / edges with the same proven arg — must agree, not pick-any into Ship. + 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) { + String code = e.getA(); + route(code); + route(code); + } + void route(String code) { + machine.sendEvent(OrderEvent.valueOf(code)); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void multiFieldWriteStaysFailClosed(@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; + RichEvent carrier; + void fire(RichEvent e) { + this.carrier = e; + this.carrier = new ShipRichEvent(); + machine.sendEvent(OrderEvent.valueOf(carrier.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertFailClosed(source, tempDir, "pay"); + } + + @Test + void multiSetterWriteStaysFailClosed(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(); } + } + class Dispatcher { + StateMachine machine; + void fire() { + EventWrapper w = new EventWrapper(); + w.setEvent(new PayRichEvent()); + w.setEvent(new ShipRichEvent()); + machine.sendEvent(OrderEvent.valueOf(w.getEvent().getA())); + } + } + class EventWrapper { + RichEvent event; + void setEvent(RichEvent e) { this.event = e; } + RichEvent getEvent() { return event; } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertFailClosed(source, tempDir, "pay"); + } + + @Test + void replaceStringTransformStaysFailClosed(@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.getA().replace("X", ""))); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertFailClosed(source, tempDir, "pay"); + } + + @Test + void sendEventMonoRecoveryResolves(@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.sendEventMono(OrderEvent.valueOf(e.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + class StateMachine { public void sendEventMono(OrderEvent e) {} } + enum OrderEvent { PAY, SHIP, CANCEL } + enum OrderState { NEW, PAID, SHIPPED, CANCELLED } + class OrderStateMachineConfig {} + """; + assertResolvedPay(source, tempDir, "sendEventMono"); + } + + @Test + void recoveryDisagreeingUniqueProofsStaysFailClosed(@TempDir Path tempDir) throws Exception { + // Single sendEvent chain, but recovery sees two unique disagreeing valueOf proofs. + 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) { + OrderEvent.valueOf(new ShipRichEvent().getA()); + machine.sendEvent(OrderEvent.valueOf(e.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertFailClosed(source, tempDir, "pay"); + } + + @Test + void opaqueExternalValidateStaysFailClosed(@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) { + OrderEvent resolved = UnknownExternalClass.validate(e.getA()); + machine.sendEvent(resolved); + } + } + class UnknownExternalClass { + static OrderEvent validate(String code) { return null; } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertFailClosed(source, tempDir, "pay"); + } + + @Test + void noAllocFieldStaysFailClosed(@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; + protected OrderEvent toEvent(String event) { + return OrderEvent.valueOf(event); + } + void fire() { + machine.sendEvent(toEvent(carrier.getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertFailClosed(source, tempDir, "dispatch"); + } + + @Test + void instanceofPatternNarrowingResolves(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(new Object()); } + } + class Dispatcher { + StateMachine machine; + void fire(Object o) { + if (o instanceof PayRichEvent p) { + machine.sendEvent(OrderEvent.valueOf(p.getA())); + } + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void instanceofThenBranchNarrowingResolves(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(new Object()); } + } + class Dispatcher { + StateMachine machine; + void fire(Object o) { + if (o instanceof PayRichEvent) { + machine.sendEvent(OrderEvent.valueOf(((PayRichEvent) o).getA())); + } + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void sendOutsideInstanceofStaysFailClosed(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(new Object()); } + } + class Dispatcher { + StateMachine machine; + void fire(Object o) { + if (o instanceof PayRichEvent p) { + // narrowed but unused + } + machine.sendEvent(OrderEvent.valueOf(((RichEvent) o).getA())); + } + } + interface RichEvent { String getA(); } + class PayRichEvent implements RichEvent { + public String getA() { return OrderEvent.PAY.name(); } + } + class ShipRichEvent implements RichEvent { + public String getA() { return OrderEvent.SHIP.name(); } + } + """ + TAIL; + assertFailClosed(source, tempDir, "pay"); + } + + @Test + void literalSupplierLambdaResolves(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import java.util.function.Supplier; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(() -> OrderEvent.PAY); } + } + class Dispatcher { + StateMachine machine; + void fire(Supplier provider) { + machine.sendEvent(provider.get()); + } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void opaqueSupplierFieldStaysFailClosed(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import java.util.function.Supplier; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(); } + } + class Dispatcher { + StateMachine machine; + Supplier provider; + void fire() { + machine.sendEvent(provider.get()); + } + } + """ + TAIL; + assertFailClosed(source, tempDir, "pay"); + } + + @Test + void messageBuilderWithPayloadResolves(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(); } + } + class Dispatcher { + StateMachine machine; + void fire() { + machine.sendEvent(MessageBuilder.withPayload(OrderEvent.PAY).build()); + } + } + class MessageBuilder { + static MessageBuilder withPayload(OrderEvent e) { return new MessageBuilder(); } + MessageBuilder setHeader(String k, Object v) { return this; } + OrderEvent build() { return null; } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void genericMessageCtorPayloadResolves(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(); } + } + class Dispatcher { + StateMachine machine; + void fire() { + machine.sendEvent(new GenericMessage<>(OrderEvent.PAY)); + } + } + class GenericMessage { + GenericMessage(T payload) {} + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void messageBuilderEventHeaderResolves(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(); } + } + class Dispatcher { + StateMachine machine; + void fire() { + Object dto = new Object(); + machine.sendEvent(MessageBuilder.withPayload(dto).setHeader("event", OrderEvent.PAY).build()); + } + } + class MessageBuilder { + static MessageBuilder withPayload(Object e) { return new MessageBuilder(); } + MessageBuilder setHeader(String k, Object v) { return this; } + OrderEvent build() { return null; } + } + """ + TAIL; + assertResolvedPay(source, tempDir); + } + + @Test + void freeFormHeaderKeyStaysFailClosed(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + Dispatcher dispatcher; + public void pay() { dispatcher.fire(); } + } + class Dispatcher { + StateMachine machine; + void fire() { + Object dto = new Object(); + var msg = MessageBuilder.withPayload(dto).setHeader("customKey", OrderEvent.PAY).build(); + machine.sendEvent((OrderEvent) msg.getHeaders().get("customKey")); + } + } + class MessageBuilder { + static MessageBuilder withPayload(Object e) { return new MessageBuilder(); } + MessageBuilder setHeader(String k, Object v) { return this; } + Message build() { return new Message(); } + } + class Message { + Headers getHeaders() { return new Headers(); } + } + class Headers { + Object get(String k) { return null; } + } + """ + TAIL; + assertFailClosed(source, tempDir, "pay"); + } + + private static void assertFailClosed(String source, Path tempDir, String entryMethod) 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); + } + + private static void assertResolvedPay(String source, Path tempDir) throws Exception { + assertResolvedPay(source, tempDir, "sendEvent"); + } + + private static void assertResolvedPay(String source, Path tempDir, String triggerMethod) 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", + triggerMethod, + MACHINE_CONFIG, + EVENT_TYPE, + STATE_TYPE, + EVENT_TYPE + ".PAY", + EngineKind.JDT, + pay, + ship, + cancel); + assertThat(linked.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED); + } +}