Perf: use CFG dataflow for traceLocalSetter and guard hot-path logging.

Replace per-call method-body AST scans with reaching-definitions lookup, guard remaining debug/trace calls in call-graph hot paths, and add branch edge-case tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-12 10:14:02 +02:00
parent 82a70b5374
commit 411b7a0e52
7 changed files with 571 additions and 81 deletions

View File

@@ -194,6 +194,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
protected TriggerPoint resolveTriggerPointParametersOriginal(TriggerPoint tp, List<String> path, Map<String, List<CallEdge>> 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<String> 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<String> 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<String> 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<String> 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<String> enumValues = context.getEnumValues(declaredType);
if (enumValues != null && !enumValues.isEmpty()) {

View File

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

View File

@@ -595,11 +595,15 @@ public class ConstructorAnalyzer {
try {
Map<String, String> 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);

View File

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

View File

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

View File

@@ -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<ControlFlowGraph.CfgNode> 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<ControlFlowGraph.CfgNode> orderedStatementNodes(ControlFlowGraph cfg) {
List<ControlFlowGraph.CfgNode> 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<ControlFlowGraph.CfgNode> 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<ControlFlowGraph.CfgNode> pathNodes = new HashSet<>();
Set<ASTNode> defs = rd.getReachingDefinitions(receiver, receiver.getIdentifier());
for (ASTNode def : defs) {
ControlFlowGraph.CfgNode defCfgNode = findCfgNode(cfg, def);
if (defCfgNode == null) {
continue;
}
Set<ControlFlowGraph.CfgNode> reachFromDef = forwardReach(defCfgNode);
Set<ControlFlowGraph.CfgNode> reachToUse = backwardReach(useCfgNode);
Set<ControlFlowGraph.CfgNode> between = new HashSet<>(reachFromDef);
between.retainAll(reachToUse);
pathNodes.addAll(between);
}
if (pathNodes.isEmpty()) {
return orderedStatementNodes(cfg);
}
List<ControlFlowGraph.CfgNode> ordered = new ArrayList<>();
for (ControlFlowGraph.CfgNode node : cfg.getNodes()) {
if (pathNodes.contains(node)) {
ordered.add(node);
}
}
return ordered;
}
private Set<ControlFlowGraph.CfgNode> forwardReach(ControlFlowGraph.CfgNode start) {
Set<ControlFlowGraph.CfgNode> reached = new HashSet<>();
Queue<ControlFlowGraph.CfgNode> 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<ControlFlowGraph.CfgNode> backwardReach(ControlFlowGraph.CfgNode end) {
Set<ControlFlowGraph.CfgNode> reached = new HashSet<>();
Queue<ControlFlowGraph.CfgNode> 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;

View File

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