This commit is contained in:
2026-07-16 21:05:01 +02:00
parent 139eee0a43
commit 08adbcb5ff
2 changed files with 279 additions and 29 deletions

View File

@@ -4,6 +4,7 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary;
import click.kamil.springstatemachineexporter.analysis.pipeline.AccessorResolver;
import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder;
import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry;
import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper;
import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport;
import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget;
@@ -268,7 +269,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
final boolean debug = log.isDebugEnabled();
try {
String event = tp.getEvent();
event = unwrapConverterMethods(event);
String triggerScope = path.isEmpty() ? null : path.get(path.size() - 1);
event = unwrapConverterMethods(event, triggerScope);
String currentParamName = event;
String resolvedValue = event;
String methodSuffix = "";
@@ -360,7 +362,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (paramIndex < edge.getArguments().size()) {
String arg = edge.getArguments().get(paramIndex);
if (arg != null) {
arg = unwrapConverterMethods(arg);
arg = unwrapConverterMethods(arg, caller);
String receiver = edge.getReceiver();
String actualReceiver = null;
if ("this".equals(arg) || arg.startsWith("this.") || "super".equals(arg) || arg.startsWith("super.")) {
@@ -1041,16 +1043,36 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
&& (fallbackMi.getExpression() == null || fallbackMi.getExpression() instanceof ThisExpression)) {
TypeDeclaration enclosingType = findEnclosingType(fallbackMi);
CompilationUnit fallbackCu = fallbackMi.getRoot() instanceof CompilationUnit cu ? cu : null;
List<String> ownerCandidates = new ArrayList<>();
if (enclosingType != null) {
List<String> switchConstants = resolveLocalSwitchMethodConstants(enclosingType, fallbackMi);
for (String switchConstant : switchConstants) {
if (switchConstant != null && !switchConstant.startsWith("<SYMBOLIC:")
&& !polymorphicEvents.contains(switchConstant)) {
polymorphicEvents.add(switchConstant);
ownerCandidates.add(context.getFqn(enclosingType));
}
// String-parsed expressions have no enclosing AST type — walk the call path instead.
for (String methodFqn : path) {
if (methodFqn != null && methodFqn.contains(".")) {
String owner = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
if (!ownerCandidates.contains(owner)) {
ownerCandidates.add(owner);
}
}
}
for (String ownerFqn : ownerCandidates) {
TypeDeclaration ownerTd = context.getTypeDeclaration(ownerFqn);
if (ownerTd == null
|| context.findMethodDeclaration(ownerTd, fallbackMi.getName().getIdentifier(), true) == null) {
continue;
}
if (enclosingType != null && ownerFqn.equals(context.getFqn(enclosingType))) {
List<String> switchConstants = resolveLocalSwitchMethodConstants(enclosingType, fallbackMi);
for (String switchConstant : switchConstants) {
if (switchConstant != null && !switchConstant.startsWith("<SYMBOLIC:")
&& !polymorphicEvents.contains(switchConstant)) {
polymorphicEvents.add(switchConstant);
}
}
}
List<String> methodReturns = constantExtractor.resolveMethodReturnConstant(
context.getFqn(enclosingType),
ownerFqn,
fallbackMi.getName().getIdentifier(),
0,
new HashSet<>(),
@@ -1061,6 +1083,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
polymorphicEvents.add(methodReturn);
}
}
if (!polymorphicEvents.isEmpty()) {
break;
}
}
}
}
@@ -2732,37 +2757,74 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
/**
* Peels enum factory wrappers while tracing call-path arguments, e.g.
* Peels enum factory / library wrappers while tracing call-path arguments, e.g.
* {@code OrderEvent.valueOf(action)} → {@code action} so the next hop can resolve the parameter.
* <p>
* Does <em>not</em> peel implicit-{@code this} domain helpers ({@code toEvent(msg)}, {@code resolve(x)}).
* Those must stay intact so {@link ConstantExtractor} / {@link AccessorResolver} can widen to
* subclass implementations and walk overridden return statements. Pure passthrough helpers that
* literally {@code return arg;} are still peeled via {@link MethodInvocationUnwrapper}.
* <p>
* Map/collection lookups ({@code ROUTES.get(key)}) are intentionally <em>not</em> peeled — they are
* routing tables, not enum converters. See {@link ExpressionAccessClassifier}.
*/
private String unwrapConverterMethods(String arg) {
private String unwrapConverterMethods(String arg, String scopeMethodFqn) {
if (arg == null) return null;
String current = arg;
while (current.contains("(") && !current.startsWith("new ")) {
org.eclipse.jdt.core.dom.ASTNode argNode = parseExpressionString(current);
if (argNode instanceof org.eclipse.jdt.core.dom.MethodInvocation miArg && !miArg.arguments().isEmpty()) {
if (expressionAccessClassifier.isKeyedLookup(miArg, null)) {
break;
ASTNode argNode = parseExpressionString(current);
if (!(argNode instanceof MethodInvocation miArg) || miArg.arguments().isEmpty()) {
break;
}
if (expressionAccessClassifier.isKeyedLookup(miArg, scopeMethodFqn)) {
break;
}
String methodName = miArg.getName().getIdentifier();
Expression recv = miArg.getExpression();
// Type.valueOf(x) / Enum.valueOf(Class, x) — peel to the name argument.
if ("valueOf".equals(methodName) && isTypeLikeReceiver(recv)) {
Expression innerArg = (Expression) miArg.arguments().get(0);
if (miArg.arguments().size() == 2) {
innerArg = (Expression) miArg.arguments().get(1);
}
boolean shouldUnpack = false;
org.eclipse.jdt.core.dom.Expression recv = miArg.getExpression();
if (recv == null || recv instanceof org.eclipse.jdt.core.dom.ThisExpression) {
shouldUnpack = true;
} else if (recv instanceof org.eclipse.jdt.core.dom.SimpleName sn) {
shouldUnpack = !Character.isLowerCase(sn.getIdentifier().charAt(0));
} else if (recv instanceof org.eclipse.jdt.core.dom.QualifiedName qn) {
shouldUnpack = !Character.isLowerCase(qn.getName().getIdentifier().charAt(0));
}
if (shouldUnpack) {
org.eclipse.jdt.core.dom.Expression innerArg = (org.eclipse.jdt.core.dom.Expression) miArg.arguments().get(0);
if (miArg.getName().getIdentifier().equals("valueOf") && miArg.arguments().size() == 2) {
innerArg = (org.eclipse.jdt.core.dom.Expression) miArg.arguments().get(1);
current = innerArg.toString();
continue;
}
// Optional.of / Mono.just / MessageBuilder.withPayload — peel library factories only.
if (recv != null
&& LibraryUnwrapRegistry.isUnwrapReceiverType(recv.toString())
&& LibraryUnwrapRegistry.isUnwrapMethodName(methodName)
&& !LibraryUnwrapRegistry.shouldStopUnwrap(methodName)) {
current = ((Expression) miArg.arguments().get(0)).toString();
continue;
}
// Implicit-this / this.helper(arg): only peel pure passthroughs (return the same parameter).
// Domain converters that return enum constants must remain for override/return walking.
if (recv == null || recv instanceof ThisExpression) {
Expression peeled = MethodInvocationUnwrapper.unwrap(
miArg,
0,
scopeMethodFqn,
context,
mi -> {
if (scopeMethodFqn == null || !scopeMethodFqn.contains(".")) {
return null;
}
String currentClass = scopeMethodFqn.substring(0, scopeMethodFqn.lastIndexOf('.'));
if (mi.getExpression() == null || mi.getExpression() instanceof ThisExpression) {
return currentClass + "." + mi.getName().getIdentifier();
}
return null;
});
if (peeled != null && peeled != miArg) {
String next = peeled.toString();
if (!next.equals(current)) {
current = next;
continue;
}
current = innerArg.toString();
continue;
}
}
break;
@@ -2770,6 +2832,18 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return current;
}
private static boolean isTypeLikeReceiver(Expression recv) {
if (recv instanceof SimpleName sn) {
String name = sn.getIdentifier();
return name != null && !name.isEmpty() && !Character.isLowerCase(name.charAt(0));
}
if (recv instanceof QualifiedName qn) {
String name = qn.getName().getIdentifier();
return name != null && !name.isEmpty() && !Character.isLowerCase(name.charAt(0));
}
return false;
}
private boolean shouldDropBarePolymorphicCandidate(String candidate, String expectedType, CodebaseContext context) {
if (candidate == null || candidate.contains(".")
|| candidate.startsWith("<SYMBOLIC:") || candidate.startsWith("ENUM_SET:")) {

View File

@@ -0,0 +1,176 @@
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.CodebaseContext;
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 static org.assertj.core.api.Assertions.assertThat;
/**
* Implicit-{@code this} helpers that return enum constants must not be peeled by
* {@code unwrapConverterMethods} — otherwise ConstantExtractor never widens to subclass overrides.
*/
class ImplicitThisHelperEventResolutionTest {
@Test
void shouldResolveEventFromImplicitThisHelperOverrides(@TempDir Path tempDir) throws Exception {
Path javaRoot = tempDir.resolve("src/main/java/com/example");
Files.createDirectories(javaRoot);
Files.writeString(tempDir.resolve("build.gradle"), "plugins { id 'java' }");
Files.writeString(javaRoot.resolve("OrderEvent.java"), """
package com.example;
public enum OrderEvent { PAY, SHIP, LOG }
""");
Files.writeString(javaRoot.resolve("StateMachine.java"), """
package com.example;
public class StateMachine {
void sendEvent(OrderEvent event) {}
}
""");
Files.writeString(javaRoot.resolve("AbstractDispatcher.java"), """
package com.example;
public abstract class AbstractDispatcher {
private final StateMachine sm = new StateMachine();
protected abstract OrderEvent toEvent(String message);
public void handle(String message) {
sm.sendEvent(toEvent(message));
}
}
""");
Files.writeString(javaRoot.resolve("PayDispatcher.java"), """
package com.example;
public class PayDispatcher extends AbstractDispatcher {
@Override
protected OrderEvent toEvent(String message) {
return OrderEvent.PAY;
}
}
""");
Files.writeString(javaRoot.resolve("ShipDispatcher.java"), """
package com.example;
public class ShipDispatcher extends AbstractDispatcher {
@Override
protected OrderEvent toEvent(String message) {
return OrderEvent.SHIP;
}
}
""");
Files.writeString(javaRoot.resolve("OrderController.java"), """
package com.example;
public class OrderController {
private final AbstractDispatcher dispatcher;
public OrderController(AbstractDispatcher dispatcher) {
this.dispatcher = dispatcher;
}
public void onCommand(String message) {
dispatcher.handle(message);
}
}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(tempDir.resolve("src/main/java").toString()));
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
List<CallChain> chains = engine.findChains(
List.of(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /commands")
.className("com.example.OrderController")
.methodName("onCommand")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("message")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build()),
List.of(TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("event")
.build()));
assertThat(chains).isNotEmpty();
List<String> poly = chains.stream()
.flatMap(chain -> {
List<String> events = chain.getTriggerPoint().getPolymorphicEvents();
return events == null ? java.util.stream.Stream.<String>empty() : events.stream();
})
.distinct()
.toList();
assertThat(poly)
.as("toEvent(message) must stay intact so override returns are collected")
.anyMatch(e -> e.endsWith(".PAY") || e.equals("PAY") || e.endsWith("OrderEvent.PAY"))
.anyMatch(e -> e.endsWith(".SHIP") || e.equals("SHIP") || e.endsWith("OrderEvent.SHIP"));
assertThat(poly)
.as("must not collapse to the raw message parameter after destructive unwrap")
.noneMatch(e -> "message".equals(e));
}
@Test
void shouldStillPeelValueOfForParameterTracing(@TempDir Path tempDir) throws Exception {
Path javaRoot = tempDir.resolve("src/main/java/com/example");
Files.createDirectories(javaRoot);
Files.writeString(tempDir.resolve("build.gradle"), "plugins { id 'java' }");
Files.writeString(javaRoot.resolve("OrderEvent.java"), """
package com.example;
public enum OrderEvent { PAY, SHIP }
""");
Files.writeString(javaRoot.resolve("StateMachine.java"), """
package com.example;
public class StateMachine {
void sendEvent(OrderEvent event) {}
}
""");
Files.writeString(javaRoot.resolve("OrderController.java"), """
package com.example;
public class OrderController {
private final StateMachine sm = new StateMachine();
public void onCommand(String eventStr) {
sm.sendEvent(OrderEvent.valueOf(eventStr));
}
}
""");
CodebaseContext context = new CodebaseContext();
context.setResolveBindings(true);
context.setSourcepath(List.of(tempDir.resolve("src/main/java").toString()));
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
List<CallChain> chains = engine.findChains(
List.of(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /commands/{eventStr}")
.className("com.example.OrderController")
.methodName("onCommand")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("eventStr")
.type("String")
.annotations(List.of("PathVariable"))
.build()))
.build()),
List.of(TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("event")
.build()));
assertThat(chains).isNotEmpty();
// valueOf peel must still allow tracing back to the path variable name.
assertThat(chains.stream()
.map(c -> c.getTriggerPoint().getEvent())
.anyMatch(e -> e != null && (e.contains("eventStr") || e.contains("valueOf"))))
.isTrue();
}
}