From 9a3bd3394f430c5398de6a37dc41b25f645e948a Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Wed, 15 Jul 2026 16:29:41 +0200 Subject: [PATCH] cursor out of token --- .../analysis/model/CallChain.java | 4 + .../resolver/MachineEnumCanonicalizer.java | 10 +- .../service/AbstractCallGraphEngine.java | 40 +++++- .../ast/common/JdtDataFlowModel.java | 74 ++++++++++- .../MachineEnumCanonicalizerTest.java | 11 ++ .../PathResolutionOptimizationTest.java | 120 ++++++++++++++++++ 6 files changed, 250 insertions(+), 9 deletions(-) create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathResolutionOptimizationTest.java diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallChain.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallChain.java index 0f6a42c..318f9ff 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallChain.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallChain.java @@ -1,11 +1,13 @@ package click.kamil.springstatemachineexporter.analysis.model; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.Builder; import lombok.Data; import lombok.extern.jackson.Jacksonized; import java.util.List; +import java.util.Map; @Data @Builder(toBuilder = true) @@ -19,4 +21,6 @@ public class CallChain { private final List matchedTransitions; /** How this chain's trigger was linked; derived from trigger flags and matchedTransitions. */ private final LinkResolution linkResolution; + @JsonIgnore + private final Map pathBindings; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java index ea0637d..2423f77 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java @@ -794,8 +794,14 @@ public final class MachineEnumCanonicalizer { String raw; if (isStringOrPrimitiveType(enumTypeFqn) && canonical.startsWith(enumTypeFqn + ".")) { - String constant = canonical.substring(enumTypeFqn.length() + 1); - raw = "\"" + constant + "\""; + int constantStart = enumTypeFqn.length() + 1; + if (constantStart >= canonical.length()) { + String fnRaw = toFnForm(canonical, enumTypeFqn); + raw = fnRaw != null ? fnRaw : resolvePlaceholder(state.rawName()); + } else { + String constant = canonical.substring(constantStart); + raw = "\"" + constant + "\""; + } } else { String fnRaw = toFnForm(canonical, enumTypeFqn); raw = fnRaw != null ? fnRaw : resolvePlaceholder(state.rawName()); 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 276b69b..3a30d88 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 @@ -34,6 +34,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { protected final PathBindingEvaluator pathBindingEvaluator; private final Map parsedNodeCache = new HashMap<>(); private final Map> polymorphicCallCache = new HashMap<>(); + private final Map> pathBindingsCache = new HashMap<>(); protected Map> graph; private ASTNode parseExpressionString(String expr) { @@ -82,6 +83,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { context.clearAnalysisCaches(); pathFinder.clearAnalysisCaches(); pathBindingEvaluator.clearAnalysisCaches(); + pathBindingsCache.clear(); parsedNodeCache.clear(); Map> callGraph = buildCallGraph(); List chains = new ArrayList<>(); @@ -125,6 +127,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { continue; } foundAny = true; + Map pathBindings = resolvePathBindings(path, callGraph, initialBindings); TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph, initialBindings); if (resolvedTp != null) { String contextMachineId = pathFinder.extractContextMachineId(path, callGraph); @@ -133,6 +136,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { .triggerPoint(resolvedTp) .methodChain(path) .contextMachineId(contextMachineId) + .pathBindings(pathBindings.isEmpty() ? null : Map.copyOf(pathBindings)) .build()); } } @@ -150,6 +154,33 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return resolveTriggerPointParameters(tp, path, callGraph, Map.of()); } + protected Map resolvePathBindings( + List path, + Map> callGraph, + Map initialBindings) { + if (path == null || path.size() < 2) { + return initialBindings != null ? Map.copyOf(initialBindings) : Map.of(); + } + String cacheKey = pathBindingsCacheKey(path, initialBindings); + return pathBindingsCache.computeIfAbsent(cacheKey, ignored -> { + Map traced = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder); + return mergeBindings(initialBindings, traced); + }); + } + + private static String pathBindingsCacheKey(List path, Map initialBindings) { + StringJoiner joiner = new StringJoiner("\0"); + for (String hop : path) { + joiner.add(hop); + } + if (initialBindings != null) { + initialBindings.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> joiner.add(entry.getKey() + "=" + entry.getValue())); + } + return joiner.toString(); + } + protected TriggerPoint resolveTriggerPointParameters( TriggerPoint tp, List path, @@ -3064,8 +3095,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (isExternalParameter(entryMethod, finalParamName)) { return true; } - Map bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder); - bindings = mergeBindings(initialBindings, bindings); + Map bindings = resolvePathBindings(path, callGraph, initialBindings); for (String boundName : bindings.keySet()) { if (isExternalParameter(entryMethod, boundName)) { return true; @@ -3102,7 +3132,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return sl.getLiteralValue(); } if (keyArg instanceof SimpleName sn) { - Map bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder); + Map bindings = resolvePathBindings(path, callGraph, Map.of()); String bound = bindings.get(sn.getIdentifier()); if (bound != null) { return unwrapStringLiteral(bound); @@ -3130,7 +3160,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (!(keyArg instanceof SimpleName sn)) { return false; } - Map bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder); + Map bindings = resolvePathBindings(path, callGraph, Map.of()); if (bindings.containsKey(sn.getIdentifier())) { return true; } @@ -3189,7 +3219,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (polymorphicEvents == null || polymorphicEvents.isEmpty()) { return polymorphicEvents; } - Map bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder); + Map bindings = resolvePathBindings(path, callGraph, Map.of()); if (bindings.isEmpty()) { if (hasMultiClassPolymorphicPath(path) && polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant)) { 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 656728f..25eb5ce 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 @@ -1,7 +1,10 @@ package click.kamil.springstatemachineexporter.ast.common; import click.kamil.springstatemachineexporter.analysis.index.AccessorInlining; +import click.kamil.springstatemachineexporter.analysis.index.AccessorKind; +import click.kamil.springstatemachineexporter.analysis.index.AccessorNaming; import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; +import click.kamil.springstatemachineexporter.analysis.index.GetterChainEdge; import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry; import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport; import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget; @@ -460,9 +463,72 @@ public class JdtDataFlowModel implements DataFlowModel { return inlineRecordComponentRead(mi, accessor.get(), receiverDefs, visited, paramBindings, instanceFieldBindings, depth); } + List indexedHop = tryIndexedGetterChainHop( + mi, accessor.get(), visited, paramBindings, instanceFieldBindings, depth); + if (!indexedHop.isEmpty()) { + return indexedHop; + } + return inlineAccessorGetter(mi, accessor.get(), receiverDefs, visited, paramBindings, instanceFieldBindings, depth); } + /** + * Scan-time {@link GetterChainEdge} fast path for trivial field-default getter hops. + * Skipped when the receiver may have been mutated before this use site. + */ + private List tryIndexedGetterChainHop( + MethodInvocation mi, + AccessorSummary accessor, + Set visited, + Map paramBindings, + Map instanceFieldBindings, + int depth) { + if (accessor.kind() != AccessorKind.GETTER || hasReceiverMutationsBeforeUse(mi)) { + return List.of(); + } + Optional edge = context.getGetterChainIndex().lookup(accessor.ownerFqn(), accessor.methodName()); + if (edge.isEmpty() || edge.get().fieldInitializerCic() == null) { + return List.of(); + } + ClassInstanceCreation fieldDefaultCic = edge.get().fieldInitializerCic(); + return getReachingDefinitions( + fieldDefaultCic, visited, paramBindings, instanceFieldBindings, depth + 1); + } + + private boolean hasReceiverMutationsBeforeUse(MethodInvocation useSite) { + Expression receiver = useSite.getExpression(); + if (!(receiver instanceof SimpleName receiverName)) { + return false; + } + MethodDeclaration enclosingMethod = findEnclosingMethod(useSite); + if (enclosingMethod == null || enclosingMethod.getBody() == null) { + return false; + } + int usePosition = useSite.getStartPosition(); + final boolean[] mutationFound = { false }; + enclosingMethod.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if (mutationFound[0] || node.getStartPosition() >= usePosition) { + return false; + } + if (!(node.getExpression() instanceof SimpleName callReceiver)) { + return true; + } + if (!callReceiver.getIdentifier().equals(receiverName.getIdentifier())) { + return true; + } + String callee = node.getName().getIdentifier(); + if (AccessorNaming.isBeanStyleAccessorName(callee) && callee.startsWith("set")) { + mutationFound[0] = true; + return false; + } + return true; + } + }); + return mutationFound[0]; + } + private List inlineConstantGetter( AccessorSummary accessor, Set visited, @@ -819,8 +885,12 @@ public class JdtDataFlowModel implements DataFlowModel { } ASTNode defNode = findVariableDefinitionNode(enclosingMethod, receiverName.getIdentifier()); if (defNode instanceof SingleVariableDeclaration) { - String setterName = "set" + Character.toUpperCase(accessor.fieldName().charAt(0)) - + accessor.fieldName().substring(1); + String fieldName = accessor.fieldName(); + if (fieldName == null || fieldName.isEmpty()) { + return; + } + String setterName = "set" + Character.toUpperCase(fieldName.charAt(0)) + + fieldName.substring(1); Expression setterArg = findLocalSetterArgument( enclosingMethod, receiverName.getIdentifier(), diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java index 68bd636..e9d1bb1 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java @@ -433,6 +433,17 @@ class MachineEnumCanonicalizerTest { assertThat(only.rawName()).isEqualTo("OrderState.NEW"); } + @Test + void shouldNotThrowWhenStringMachineStateHasTypePrefixOnly() { + String stateTypeFqn = "com.company.project.OrderState"; + State state = State.of("PAID", stateTypeFqn + "."); + + State canonical = MachineEnumCanonicalizer.canonicalizeState(state, "String"); + + assertThat(canonical).isNotNull(); + assertThat(canonical.fullIdentifier()).isEqualTo(stateTypeFqn + "."); + } + private static void writeSampleConfig(Path tempDir) throws IOException { Path orderPkg = tempDir.resolve("com/example/order"); Path configPkg = tempDir.resolve("com/example/config"); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathResolutionOptimizationTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathResolutionOptimizationTest.java new file mode 100644 index 0000000..1aa8d43 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathResolutionOptimizationTest.java @@ -0,0 +1,120 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.ast.common.AstUtils; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.MethodInvocation; +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.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Regression tests for path-binding cache-once-per-chain and index-first getter-chain hops. + */ +class PathResolutionOptimizationTest { + + @Test + void findChainsShouldAttachPathBindingsOncePerChain(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class ApiController { + private final CommandGateway gateway = new CommandGateway(); + public void pay() { gateway.executeViaMapper("order.pay"); } + } + enum DomainCommand { ORDER_PAY, ORDER_SHIP } + enum OrderEvent { PAY, SHIP } + class CommandGateway { + private final StringCommandMapper mapper = new StringCommandMapper(); + private final CentralDispatcher central = new CentralDispatcher(); + void executeViaMapper(String commandKey) { + DomainCommand command = mapper.fromString(commandKey); + central.dispatch(command); + } + } + class StringCommandMapper { + DomainCommand fromString(String commandKey) { + return switch (commandKey) { + case "order.pay" -> DomainCommand.ORDER_PAY; + case "order.ship" -> DomainCommand.ORDER_SHIP; + default -> throw new IllegalArgumentException(commandKey); + }; + } + } + class CentralDispatcher { + void dispatch(DomainCommand command) { + switch (command) { + case ORDER_PAY -> orderPay(); + case ORDER_SHIP -> orderShip(); + } + } + void orderPay() { fire(OrderEvent.PAY); } + void orderShip() { fire(OrderEvent.SHIP); } + void fire(OrderEvent event) {} + } + """; + Files.writeString(tempDir.resolve("App.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + EntryPoint entry = EntryPoint.builder().className("com.example.ApiController").methodName("pay").build(); + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.CentralDispatcher") + .methodName("fire") + .event("event") + .build(); + + List chains = engine.findChains(List.of(entry), List.of(trigger)); + + assertThat(chains).hasSize(1); + assertThat(chains.get(0).getPathBindings()) + .containsEntry("command", "DomainCommand.ORDER_PAY"); + assertThat(chains.get(0).getMethodChain()).contains("com.example.CentralDispatcher.orderPay"); + } + + @Test + void indexFirstGetterChainHopShouldResolveFieldDefaultChain(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class OrderService { + public void pay(Order order) { + order.getPayload().getType(); + } + } + class Order { + private Payload payload = new Payload(); + public Payload getPayload() { return payload; } + } + class Payload { + private OrderEvent type = OrderEvent.PAY; + public OrderEvent getType() { return type; } + } + enum OrderEvent { PAY, SHIP } + """; + Files.writeString(tempDir.resolve("App.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + JdtDataFlowModel dataFlowModel = new JdtDataFlowModel(context); + Expression found = AstUtils.findExpressionInMethod( + "com.example.OrderService.pay", "order.getPayload().getType()", context); + assertThat(found).isInstanceOf(MethodInvocation.class); + + String resolved = dataFlowModel.resolveValue((MethodInvocation) found, context); + assertThat(resolved).contains("PAY"); + } +}