cursor out of token

This commit is contained in:
2026-07-15 16:29:41 +02:00
parent d6754b7464
commit 9a3bd3394f
6 changed files with 250 additions and 9 deletions

View File

@@ -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<MatchedTransition> matchedTransitions;
/** How this chain's trigger was linked; derived from trigger flags and matchedTransitions. */
private final LinkResolution linkResolution;
@JsonIgnore
private final Map<String, String> pathBindings;
}

View File

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

View File

@@ -34,6 +34,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
protected final PathBindingEvaluator pathBindingEvaluator;
private final Map<String, ASTNode> parsedNodeCache = new HashMap<>();
private final Map<String, List<String>> polymorphicCallCache = new HashMap<>();
private final Map<String, Map<String, String>> pathBindingsCache = new HashMap<>();
protected Map<String, List<CallEdge>> 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<String, List<CallEdge>> callGraph = buildCallGraph();
List<CallChain> chains = new ArrayList<>();
@@ -125,6 +127,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
continue;
}
foundAny = true;
Map<String, String> 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<String, String> resolvePathBindings(
List<String> path,
Map<String, List<CallEdge>> callGraph,
Map<String, String> 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<String, String> traced = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder);
return mergeBindings(initialBindings, traced);
});
}
private static String pathBindingsCacheKey(List<String> path, Map<String, String> 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<String> path,
@@ -3064,8 +3095,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (isExternalParameter(entryMethod, finalParamName)) {
return true;
}
Map<String, String> bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder);
bindings = mergeBindings(initialBindings, bindings);
Map<String, String> 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<String, String> bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder);
Map<String, String> 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<String, String> bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder);
Map<String, String> 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<String, String> bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder);
Map<String, String> bindings = resolvePathBindings(path, callGraph, Map.of());
if (bindings.isEmpty()) {
if (hasMultiClassPolymorphicPath(path)
&& polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant)) {

View File

@@ -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<Expression> 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<Expression> tryIndexedGetterChainHop(
MethodInvocation mi,
AccessorSummary accessor,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
if (accessor.kind() != AccessorKind.GETTER || hasReceiverMutationsBeforeUse(mi)) {
return List.of();
}
Optional<GetterChainEdge> 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<Expression> inlineConstantGetter(
AccessorSummary accessor,
Set<ASTNode> 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(),

View File

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

View File

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