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 1f1fb70..6d83539 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 @@ -194,6 +194,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { protected TriggerPoint resolveTriggerPointParametersOriginal(TriggerPoint tp, List path, Map> callGraph, String[] finalParamNameRef) { click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.setCurrentPath(path); + final boolean debug = log.isDebugEnabled(); try { String event = tp.getEvent(); event = unwrapConverterMethods(event); @@ -348,14 +349,18 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } } if (chainedGetterEvents != null && !chainedGetterEvents.isEmpty()) { - log.debug("Early return (chained getter): getterEvents = {}", chainedGetterEvents); + if (debug) { + log.debug("Early return (chained getter): getterEvents = {}", chainedGetterEvents); + } return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, chainedGetterEvents); } if (expressionAccessClassifier.isTraceableAccessorSuffix(methodSuffix)) { List setterEvents = resolveSetterEventsFromSuffix(currentParamName, methodSuffix, entryMethod, path); if (!setterEvents.isEmpty()) { - log.debug("Early return (scoped setter): setterEvents = {}", setterEvents); + if (debug) { + log.debug("Early return (scoped setter): setterEvents = {}", setterEvents); + } return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, setterEvents); } } @@ -366,7 +371,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (methodSuffix != null && expressionAccessClassifier.isTraceableAccessorSuffix(methodSuffix)) { List setterEvents = resolveSetterEventsFromSuffix(currentParamName, methodSuffix, entryMethod, path); if (!setterEvents.isEmpty()) { - log.debug("Early return 1: setterEvents = {}", setterEvents); + if (debug) { + log.debug("Early return 1: setterEvents = {}", setterEvents); + } return buildTriggerPointWithPolymorphicEvents(tp, resolvedValue, setterEvents); } } @@ -378,7 +385,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (hasGetterOnReceiver) { List getterEvents = evaluateGetterOnReceiver(resolvedValue, methodSuffix, entryMethod, path, callGraph); if (getterEvents != null && !getterEvents.isEmpty()) { - log.debug("Early return 2: getterEvents = {}", getterEvents); + if (debug) { + log.debug("Early return 2: getterEvents = {}", getterEvents); + } return TriggerPoint.builder() .event(resolvedValue) .className(tp.getClassName()) @@ -408,7 +417,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } } - log.debug("resolvedValue before ENUM_SET check: {} for path: {}", resolvedValue, path); + if (debug) { + log.debug("resolvedValue before ENUM_SET check: {} for path: {}", resolvedValue, path); + } List polymorphicEvents = new ArrayList<>(); if (resolvedValue.startsWith("ENUM_SET:")) { @@ -869,7 +880,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } } - log.debug("declaredType before getEnumValues: {} for exprNode: {} in {}", declaredType, exprNode, entryMethod); + if (debug) { + log.debug("declaredType before getEnumValues: {} for exprNode: {} in {}", declaredType, exprNode, entryMethod); + } if (declaredType != null) { List enumValues = context.getEnumValues(declaredType); if (enumValues != null && !enumValues.isEmpty()) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java index b4c29db..befda38 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java @@ -95,7 +95,9 @@ public class ConstantExtractor { } else if (expr instanceof MethodInvocation mi) { String methodName = mi.getName().getIdentifier(); - log.trace("Processing mi: {}", mi); + if (log.isTraceEnabled()) { + log.trace("Processing mi: {}", mi); + } if (("get".equals(methodName) || "getOrDefault".equals(methodName)) && mi.getExpression() != null) { if (isKeyedLookupMethod(mi)) { extractMapReceiverConstants(mi.getExpression(), constants); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorAnalyzer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorAnalyzer.java index 5991fe1..e463ceb 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorAnalyzer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstructorAnalyzer.java @@ -595,11 +595,15 @@ public class ConstructorAnalyzer { try { Map fieldValues = buildFieldValuesFromCIC(td, cic, context, this::evaluateMethodCallInConstructor); - log.debug("RESOLVE_GETTER: td={}, fieldValues={}", context.getFqn(td), fieldValues); + if (log.isDebugEnabled()) { + log.debug("RESOLVE_GETTER: td={}, fieldValues={}", context.getFqn(td), fieldValues); + } if (fieldValues.isEmpty()) return Collections.emptyList(); String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, visited); - log.debug("RESOLVE_GETTER: result={}", result); + if (log.isDebugEnabled()) { + log.debug("RESOLVE_GETTER: result={}", result); + } return result != null ? List.of(result) : Collections.emptyList(); } finally { visited.remove(getterKey); 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 270c2a9..31cf55b 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 @@ -200,16 +200,24 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine { nameBinding = fa.resolveFieldBinding(); } if (nameBinding instanceof IVariableBinding varBinding) { - log.debug("CALLGRAPH RESOLVING BEAN FOR BINDING: {} calling {}", varBinding.getName(), methodName); + if (log.isDebugEnabled()) { + log.debug("CALLGRAPH RESOLVING BEAN FOR BINDING: {} calling {}", varBinding.getName(), methodName); + } String concreteFqn = injectionAnalyzer.resolveInjectedBeanFqn(varBinding); if (concreteFqn != null) { - log.debug(" -> RESOLVED CONCRETE FQN: {}", concreteFqn); + if (log.isDebugEnabled()) { + log.debug(" -> RESOLVED CONCRETE FQN: {}", concreteFqn); + } return concreteFqn + "." + methodName; } else { - log.debug(" -> RESOLVE FAILED, RETURNED NULL"); + if (log.isDebugEnabled()) { + log.debug(" -> RESOLVE FAILED, RETURNED NULL"); + } } } else { - log.debug("CALLGRAPH NAME BINDING IS NOT VARIABLE: {} calling {}", nameBinding, methodName); + if (log.isDebugEnabled()) { + log.debug("CALLGRAPH NAME BINDING IS NOT VARIABLE: {} calling {}", nameBinding, methodName); + } } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java index 10dc335..8da5b7b 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java @@ -64,79 +64,48 @@ public class VariableTracer { String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); TypeDeclaration td = context.getTypeDeclaration(className); - if (td != null) { - MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); - if (md != null && md.getBody() != null) { - final Expression[] setterArg = new Expression[1]; - String propName; - String setterMethodName; - String indexedFieldName; + if (td == null) { + return null; + } + MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); + if (md == null || md.getBody() == null) { + return null; + } - if (isBeanStyleAccessorName(getterName)) { - propName = propertyNameFromAccessor(getterName); - setterMethodName = "set" + capitalizeProperty(propName); - indexedFieldName = propName; + String setterMethodName; + String indexedFieldName; + if (isBeanStyleAccessorName(getterName)) { + String propName = propertyNameFromAccessor(getterName); + setterMethodName = "set" + capitalizeProperty(propName); + indexedFieldName = propName; - String varTypeName = getVariableDeclaredType(methodFqn, varName); - if (varTypeName != null) { - CompilationUnit contextCu = md.getRoot() instanceof CompilationUnit cu ? cu : null; - TypeDeclaration varType = contextCu != null - ? context.getTypeDeclaration(varTypeName, contextCu) - : null; - if (varType == null) { - varType = context.getTypeDeclaration(varTypeName); - } - if (varType != null) { - Optional indexedSetter = context.getAccessorIndex() - .lookup(context.getFqn(varType), setterMethodName); - if (indexedSetter.isPresent() && indexedSetter.get().isSetter()) { - setterMethodName = indexedSetter.get().methodName(); - indexedFieldName = indexedSetter.get().fieldName(); - } - } - } - } else { - propName = getterName.startsWith("get") ? getterName.substring(3) : getterName; - setterMethodName = "set" + propName; - indexedFieldName = propName; + String varTypeName = getVariableDeclaredType(methodFqn, varName); + if (varTypeName != null) { + CompilationUnit contextCu = md.getRoot() instanceof CompilationUnit cu ? cu : null; + TypeDeclaration varType = contextCu != null + ? context.getTypeDeclaration(varTypeName, contextCu) + : null; + if (varType == null) { + varType = context.getTypeDeclaration(varTypeName); } - - final String resolvedSetterName = setterMethodName; - final String resolvedFieldName = indexedFieldName; - md.getBody().accept(new ASTVisitor() { - @Override - public boolean visit(MethodInvocation node) { - if (node.getName().getIdentifier().equals(resolvedSetterName) - || node.getName().getIdentifier().equalsIgnoreCase("set" + resolvedFieldName) - || node.getName().getIdentifier().equalsIgnoreCase(resolvedFieldName)) { - if (node.getExpression() instanceof SimpleName sn && sn.getIdentifier().equals(varName)) { - if (!node.arguments().isEmpty()) { - setterArg[0] = (Expression) node.arguments().get(0); - } - } - } - return super.visit(node); + if (varType != null) { + Optional indexedSetter = context.getAccessorIndex() + .lookup(context.getFqn(varType), setterMethodName); + if (indexedSetter.isPresent() && indexedSetter.get().isSetter()) { + setterMethodName = indexedSetter.get().methodName(); + indexedFieldName = indexedSetter.get().fieldName(); } - @Override - public boolean visit(Assignment node) { - if (node.getLeftHandSide() instanceof FieldAccess fa) { - if (fa.getExpression() instanceof SimpleName faSn && faSn.getIdentifier().equals(varName)) { - if (fa.getName().getIdentifier().equalsIgnoreCase(resolvedFieldName)) { - setterArg[0] = node.getRightHandSide(); - } - } - } else if (node.getLeftHandSide() instanceof QualifiedName qqn) { - if (qqn.getQualifier().getFullyQualifiedName().equals(varName)) { - if (qqn.getName().getIdentifier().equalsIgnoreCase(resolvedFieldName)) { - setterArg[0] = node.getRightHandSide(); - } - } - } - return super.visit(node); - } - }); - return setterArg[0]; + } } + } else { + String propName = getterName.startsWith("get") ? getterName.substring(3) : getterName; + setterMethodName = "set" + propName; + indexedFieldName = propName; + } + + if (dataFlowModel instanceof JdtDataFlowModel jdtDataFlowModel) { + return jdtDataFlowModel.findLocalSetterArgument( + md, varName, getterName, setterMethodName, indexedFieldName); } return null; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java index faa8931..043a62d 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java @@ -1495,6 +1495,272 @@ public class JdtDataFlowModel implements DataFlowModel { return returns; } + public Expression findLocalSetterArgument( + MethodDeclaration md, + String varName, + String getterName, + String resolvedSetterName, + String resolvedFieldName) { + if (md == null || md.getBody() == null) { + return null; + } + + ControlFlowGraph cfg = cfgCache.computeIfAbsent(md, CfgBuilder::build); + MethodInvocation getterUse = findGetterUseOnVariable(cfg, varName, getterName); + + List nodesToScan; + ControlFlowGraph.CfgNode getterCfgNode = null; + if (getterUse != null && getterUse.getExpression() instanceof SimpleName receiver) { + getterCfgNode = findCfgNode(cfg, getterUse); + nodesToScan = collectCfgNodesBetweenDefAndUse(md, receiver, getterUse, cfg); + } else { + nodesToScan = orderedStatementNodes(cfg); + } + + Expression lastSetterArg = null; + for (ControlFlowGraph.CfgNode node : nodesToScan) { + if (getterCfgNode != null && node == getterCfgNode) { + continue; + } + Expression arg = extractSetterArgument(node.getAstNode(), varName, resolvedSetterName, resolvedFieldName); + if (arg != null) { + lastSetterArg = arg; + } + } + return lastSetterArg; + } + + private List orderedStatementNodes(ControlFlowGraph cfg) { + List result = new ArrayList<>(); + for (ControlFlowGraph.CfgNode node : cfg.getNodes()) { + if (node.getType() == ControlFlowGraph.CfgNodeType.STATEMENT && node.getAstNode() != null) { + result.add(node); + } + } + result.sort(Comparator.comparingInt(cfg.getNodes()::indexOf)); + return result; + } + + private MethodInvocation findGetterUseOnVariable(ControlFlowGraph cfg, String varName, String getterName) { + if (getterName == null || getterName.isEmpty()) { + return null; + } + MethodInvocation lastMatch = null; + for (ControlFlowGraph.CfgNode node : orderedStatementNodes(cfg)) { + MethodInvocation match = findGetterInvocationInNode(node.getAstNode(), varName, getterName); + if (match != null) { + lastMatch = match; + } + } + return lastMatch; + } + + private MethodInvocation findGetterInvocationInNode(ASTNode node, String varName, String getterName) { + Expression expr = unwrapCfgStatementExpression(node); + if (expr == null) { + return null; + } + final MethodInvocation[] found = new MethodInvocation[1]; + if (expr instanceof MethodInvocation mi && matchesGetterInvocation(mi, varName, getterName)) { + found[0] = mi; + } + expr.accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation nested) { + if (nested != expr && matchesGetterInvocation(nested, varName, getterName)) { + found[0] = nested; + } + return super.visit(nested); + } + }); + return found[0]; + } + + private boolean matchesGetterInvocation(MethodInvocation mi, String varName, String getterName) { + if (!(mi.getExpression() instanceof SimpleName sn) || !sn.getIdentifier().equals(varName)) { + return false; + } + String methodId = mi.getName().getIdentifier(); + if (methodId.equals(getterName) || methodId.equalsIgnoreCase(getterName)) { + return true; + } + if (!getterName.startsWith("get") && !getterName.startsWith("is")) { + return methodId.equals("get" + getterName) || methodId.equalsIgnoreCase("get" + getterName); + } + return false; + } + + private List collectCfgNodesBetweenDefAndUse( + MethodDeclaration md, + SimpleName receiver, + MethodInvocation useSite, + ControlFlowGraph cfg) { + ReachingDefinitions rd = getReachingDefinitionsForMethod(md); + if (rd == null) { + return orderedStatementNodes(cfg); + } + + ControlFlowGraph.CfgNode useCfgNode = findCfgNode(cfg, useSite); + if (useCfgNode == null) { + return orderedStatementNodes(cfg); + } + + Set pathNodes = new HashSet<>(); + Set defs = rd.getReachingDefinitions(receiver, receiver.getIdentifier()); + for (ASTNode def : defs) { + ControlFlowGraph.CfgNode defCfgNode = findCfgNode(cfg, def); + if (defCfgNode == null) { + continue; + } + Set reachFromDef = forwardReach(defCfgNode); + Set reachToUse = backwardReach(useCfgNode); + Set between = new HashSet<>(reachFromDef); + between.retainAll(reachToUse); + pathNodes.addAll(between); + } + + if (pathNodes.isEmpty()) { + return orderedStatementNodes(cfg); + } + + List ordered = new ArrayList<>(); + for (ControlFlowGraph.CfgNode node : cfg.getNodes()) { + if (pathNodes.contains(node)) { + ordered.add(node); + } + } + return ordered; + } + + private Set forwardReach(ControlFlowGraph.CfgNode start) { + Set reached = new HashSet<>(); + Queue queue = new LinkedList<>(); + queue.add(start); + reached.add(start); + while (!queue.isEmpty()) { + ControlFlowGraph.CfgNode current = queue.poll(); + for (ControlFlowGraph.CfgNode succ : current.getSuccessors()) { + if (reached.add(succ)) { + queue.add(succ); + } + } + } + return reached; + } + + private Set backwardReach(ControlFlowGraph.CfgNode end) { + Set reached = new HashSet<>(); + Queue queue = new LinkedList<>(); + queue.add(end); + reached.add(end); + while (!queue.isEmpty()) { + ControlFlowGraph.CfgNode current = queue.poll(); + for (ControlFlowGraph.CfgNode pred : current.getPredecessors()) { + if (reached.add(pred)) { + queue.add(pred); + } + } + } + return reached; + } + + private Expression extractSetterArgument( + ASTNode node, + String varName, + String resolvedSetterName, + String resolvedFieldName) { + Expression expr = unwrapCfgStatementExpression(node); + if (expr == null) { + return null; + } + + Expression direct = matchSetterExpression(expr, varName, resolvedSetterName, resolvedFieldName); + if (direct != null) { + return direct; + } + if (expr instanceof Assignment assignment) { + return matchAssignmentSetter(assignment, varName, resolvedFieldName); + } + + final Expression[] found = new Expression[1]; + expr.accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation nested) { + Expression arg = matchSetterExpression(nested, varName, resolvedSetterName, resolvedFieldName); + if (arg != null) { + found[0] = arg; + } + return super.visit(nested); + } + + @Override + public boolean visit(Assignment assignment) { + Expression arg = matchAssignmentSetter(assignment, varName, resolvedFieldName); + if (arg != null) { + found[0] = arg; + } + return super.visit(assignment); + } + }); + return found[0]; + } + + private Expression unwrapCfgStatementExpression(ASTNode node) { + if (node instanceof ExpressionStatement es) { + return es.getExpression(); + } + if (node instanceof VariableDeclarationStatement vds && !vds.fragments().isEmpty()) { + VariableDeclarationFragment frag = (VariableDeclarationFragment) vds.fragments().get(0); + return frag.getInitializer(); + } + if (node instanceof Assignment assignment) { + return assignment; + } + if (node instanceof Expression expr) { + return expr; + } + return null; + } + + private Expression matchSetterExpression( + Expression expr, + String varName, + String resolvedSetterName, + String resolvedFieldName) { + if (!(expr instanceof MethodInvocation mi)) { + return null; + } + String methodId = mi.getName().getIdentifier(); + if (!methodId.equals(resolvedSetterName) + && !methodId.equalsIgnoreCase("set" + resolvedFieldName) + && !methodId.equalsIgnoreCase(resolvedFieldName)) { + return null; + } + if (mi.getExpression() instanceof SimpleName sn && sn.getIdentifier().equals(varName) + && !mi.arguments().isEmpty()) { + return (Expression) mi.arguments().get(0); + } + return null; + } + + private Expression matchAssignmentSetter(Assignment assignment, String varName, String resolvedFieldName) { + Expression lhs = assignment.getLeftHandSide(); + if (lhs instanceof FieldAccess fa) { + if (fa.getExpression() instanceof SimpleName faSn && faSn.getIdentifier().equals(varName)) { + if (fa.getName().getIdentifier().equalsIgnoreCase(resolvedFieldName)) { + return assignment.getRightHandSide(); + } + } + } else if (lhs instanceof QualifiedName qqn) { + if (qqn.getQualifier().getFullyQualifiedName().equals(varName)) { + if (qqn.getName().getIdentifier().equalsIgnoreCase(resolvedFieldName)) { + return assignment.getRightHandSide(); + } + } + } + return null; + } + @Override public String resolveValue(Expression expr, CodebaseContext context) { if (expr == null) return null; diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/common/FindLocalSetterArgumentTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/common/FindLocalSetterArgumentTest.java new file mode 100644 index 0000000..1478b6e --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/common/FindLocalSetterArgumentTest.java @@ -0,0 +1,228 @@ +package click.kamil.springstatemachineexporter.ast.common; + +import click.kamil.springstatemachineexporter.analysis.service.VariableTracer; +import org.eclipse.jdt.core.dom.Expression; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class FindLocalSetterArgumentTest { + + private CodebaseContext context; + private VariableTracer variableTracer; + + @BeforeEach + void setUp(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Payload.java", """ + package com.example; + public class Payload { + private String event = "DEFAULT"; + public String getEvent() { return event; } + public void setEvent(String event) { this.event = event; } + } + """); + context = new CodebaseContext(); + context.setResolveBindings(true); + context.setSourcepath(List.of(tempDir.toAbsolutePath().toString())); + variableTracer = new VariableTracer(context, context.getConstantResolver()); + } + + @Test + void lastSetterBeforeGetterWins(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public void run() { + Payload payload = new Payload(); + payload.setEvent("FIRST"); + payload.setEvent("SECOND"); + String value = payload.getEvent(); + } + } + """); + context.scan(tempDir); + + Expression result = variableTracer.traceLocalSetter( + "com.example.Runner.run", "payload", "getEvent"); + + assertThat(result).isNotNull(); + assertThat(result.toString()).contains("SECOND"); + } + + @Test + void setterAfterGetterIsIgnored(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public void run() { + Payload payload = new Payload(); + payload.setEvent("BEFORE"); + String value = payload.getEvent(); + payload.setEvent("AFTER"); + } + } + """); + context.scan(tempDir); + + Expression result = variableTracer.traceLocalSetter( + "com.example.Runner.run", "payload", "getEvent"); + + assertThat(result).isNotNull(); + assertThat(result.toString()).contains("BEFORE"); + assertThat(result.toString()).doesNotContain("AFTER"); + } + + @Test + void ifElseBranchesBothReachGetter_lastOnCfgPathWins(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public void run(boolean flag) { + Payload payload = new Payload(); + if (flag) { + payload.setEvent("IF_BRANCH"); + } else { + payload.setEvent("ELSE_BRANCH"); + } + String value = payload.getEvent(); + } + } + """); + context.scan(tempDir); + + Expression result = variableTracer.traceLocalSetter( + "com.example.Runner.run", "payload", "getEvent"); + + assertThat(result).isNotNull(); + // Both branches are on the CFG path to the getter; last in source order wins. + assertThat(result.toString()).contains("ELSE_BRANCH"); + } + + @Test + void setterOnSiblingBranchIsExcluded(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public void run(boolean flag) { + Payload payload = new Payload(); + if (flag) { + payload.setEvent("TAKEN"); + String value = payload.getEvent(); + return; + } + payload.setEvent("OTHER_BRANCH"); + } + } + """); + context.scan(tempDir); + + Expression result = variableTracer.traceLocalSetter( + "com.example.Runner.run", "payload", "getEvent"); + + assertThat(result).isNotNull(); + assertThat(result.toString()).contains("TAKEN"); + assertThat(result.toString()).doesNotContain("OTHER_BRANCH"); + } + + @Test + void noGetterCallFallsBackToOrderedScan(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public void run() { + Payload payload = new Payload(); + payload.setEvent("ONLY"); + } + } + """); + context.scan(tempDir); + + Expression result = variableTracer.traceLocalSetter( + "com.example.Runner.run", "payload", "getEvent"); + + assertThat(result).isNotNull(); + assertThat(result.toString()).contains("ONLY"); + } + + @Test + void fieldAssignmentSetterIsFound(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/MutablePayload.java", """ + package com.example; + public class MutablePayload { + public String event; + public String getEvent() { return event; } + } + """); + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public void run() { + MutablePayload payload = new MutablePayload(); + payload.event = "ASSIGNED"; + String value = payload.getEvent(); + } + } + """); + context.scan(tempDir); + + Expression result = variableTracer.traceLocalSetter( + "com.example.Runner.run", "payload", "getEvent"); + + assertThat(result).isNotNull(); + assertThat(result.toString()).contains("ASSIGNED"); + } + + @Test + void setterInsideNestedExpressionIsFound(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public void run() { + Payload payload = new Payload(); + consume(payload.setEvent("NESTED")); + String value = payload.getEvent(); + } + private void consume(String ignored) {} + } + """); + context.scan(tempDir); + + Expression result = variableTracer.traceLocalSetter( + "com.example.Runner.run", "payload", "getEvent"); + + assertThat(result).isNotNull(); + assertThat(result.toString()).contains("NESTED"); + } + + @Test + void returnsNullWhenNoMatchingSetter(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public void run() { + Payload payload = new Payload(); + String value = payload.getEvent(); + } + } + """); + context.scan(tempDir); + + Expression result = variableTracer.traceLocalSetter( + "com.example.Runner.run", "payload", "getEvent"); + + assertThat(result).isNull(); + } + + private static void writeJava(Path tempDir, String relativePath, String source) throws IOException { + Path file = tempDir.resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.writeString(file, source); + } +}